// Port of rich/text.py.
//
// Offsets (spans, `length`, `divide`, ...) are UTF-16 offsets.
///|
/// A marked up region in some text.
pub(all) struct Span {
start : Int
end : Int
style : StyleType
} derive(Eq, Hash)
///|
pub fn Span::new(start : Int, end : Int, style : &IntoStyle) -> Span {
{ start, end, style: style.into_style_type(), }
}
///|
/// Upstream `repr(span)`, e.g. `Span(0, 5, 'bold')`.
pub impl Show for Span with fn output(self, logger) {
logger.write_string("Span(\{self.start}, \{self.end}, \{self.style.repr()})")
}
///|
pub impl Debug for Span with fn to_repr(self) {
@debug.Repr(self.to_string())
}
///|
/// Upstream `bool(span)`.
pub fn Span::to_bool(self : Span) -> Bool {
self.end > self.start
}
///|
/// Split a span in to 2 from a given offset.
pub fn Span::split(self : Span, offset : Int) -> (Span, Span?) {
if offset < self.start || offset >= self.end {
return (self, None)
}
let span1 = {
start: self.start,
end: @cmp.minimum(self.end, offset),
style: self.style,
}
let span2 = { start: span1.end, end: self.end, style: self.style, }
(span1, Some(span2))
}
///|
/// Move start and end by a given offset.
pub fn Span::move_(self : Span, offset : Int) -> Span {
{ start: self.start + offset, end: self.end + offset, style: self.style, }
}
///|
/// Crop the span at the given offset.
pub fn Span::right_crop(self : Span, offset : Int) -> Span {
if offset >= self.end {
return self
}
{ start: self.start, end: @cmp.minimum(offset, self.end), style: self.style, }
}
///|
/// Extend the span by the given number of cells.
pub fn Span::extend_(self : Span, cells : Int) -> Span {
if cells != 0 {
{ start: self.start, end: self.end + cells, style: self.style, }
} else {
self
}
}
///|
/// Text with color / style.
pub(all) struct Text {
priv mut parts : Array[String]
priv mut length_ : Int
priv mut spans_ : Array[Span]
/// Base style for the text.
mut style : StyleType
/// Justify method, or `None` for the default.
mut justify : JustifyMethod?
/// Overflow method, or `None` for the default.
mut overflow : OverflowMethod?
/// Disable wrapping, or `None` for the default.
mut no_wrap : Bool?
/// String to end the text with.
mut end : String
/// Number of spaces per tab, or `None` to use `console.tab_size`.
mut tab_size : Int?
}
///|
/// Remove control codes that would break the layout (bell, backspace,
/// vertical tab, form feed and carriage return).
pub fn strip_control_codes(text : String) -> String {
let mut needs = false
for i in 0.. String {
let buf = StringBuilder()
for c in text {
match c.to_int() {
7 => buf.write_string("\\a")
8 => buf.write_string("\\b")
11 => buf.write_string("\\v")
12 => buf.write_string("\\f")
13 => buf.write_string("\\r")
_ => buf.write_char(c)
}
}
buf.to_string()
}
///|
/// Create a new Text.
pub fn Text::new(
text? : String = "",
style? : &IntoStyle,
justify? : JustifyMethod,
overflow? : OverflowMethod,
no_wrap? : Bool,
end? : String = "\n",
tab_size? : Int,
spans? : Array[Span],
) -> Text {
let sanitized = strip_control_codes(text)
{
parts: [sanitized],
length_: sanitized.length(),
spans_: match spans {
Some(s) => s
None => []
},
style: style_or_empty(style),
justify,
overflow,
no_wrap,
end,
tab_size,
}
}
///|
/// Length of the text (in UTF-16 code units).
pub fn Text::length(self : Text) -> Int {
self.length_
}
///|
/// Upstream `bool(text)`.
pub fn Text::to_bool(self : Text) -> Bool {
self.length_ != 0
}
///|
/// The text as a single string.
pub fn Text::plain(self : Text) -> String {
if self.parts.length() != 1 {
let joined = self.parts.join("")
self.parts = [joined]
}
self.parts[0]
}
///|
/// Set the text to a new value.
pub fn Text::set_plain(self : Text, new_text : String) -> Unit {
if new_text != self.plain() {
let sanitized = strip_control_codes(new_text)
self.parts = [sanitized]
let old_length = self.length_
self.length_ = sanitized.length()
if old_length > self.length_ {
self.trim_spans()
}
}
}
///|
/// `Show` gives the plain text, like upstream `str(text)`.
pub impl Show for Text with fn output(self, logger) {
logger.write_string(self.plain())
}
///|
/// Upstream `repr(text)`.
pub fn Text::repr(self : Text) -> String {
" s.to_string()).join(", ")}] \{self.style.repr()}>"
}
///|
pub impl Debug for Text with fn to_repr(self) {
@debug.Repr(self.repr())
}
///|
/// Two texts are equal if they have the same plain text and spans.
pub impl Eq for Text with fn equal(self, other) {
self.plain() == other.plain() && self.spans_ == other.spans_
}
///|
/// Concatenate (upstream `text + other`).
pub impl Add for Text with fn add(self, other) {
let result = self.copy()
if other.length_ != 0 {
result.append_text(other) |> ignore
}
result
}
///|
/// Concatenate a string or Text (upstream `text + other`).
pub fn Text::concat(self : Text, other : &IntoText) -> Text {
let result = self.copy()
(result.append(other) |> ignore) catch {
_ => ()
}
result
}
///|
/// Upstream `other in text`.
pub fn Text::contains(self : Text, other : &IntoText) -> Bool {
match other.into_text_type() {
Str(s) => self.plain().contains(s)
Text(t) => self.plain().contains(t.plain())
}
}
///|
/// The character at `offset` as a Text (upstream `text[offset]`).
pub fn Text::at(self : Text, offset : Int) -> Text {
let plain = self.plain()
let offset = if offset < 0 { plain.length() + offset } else { offset }
let spans = []
for span in self.spans_ {
if span.end > offset && offset >= span.start {
spans.push({ start: 0, end: 1, style: span.style, })
}
}
Text::new(
text=plain.view(start_offset=offset, end_offset=offset + 1).to_owned(),
spans~,
end="",
)
}
///|
/// A slice of the text (upstream `text[start:stop]`, Python slice
/// semantics for negative and out-of-range indices).
pub fn Text::slice(self : Text, start? : Int, stop? : Int) -> Text {
let length = self.plain().length()
fn clamp(i : Int?, default : Int) -> Int {
match i {
None => default
Some(i) =>
if i < 0 {
@cmp.maximum(0, length + i)
} else {
@cmp.minimum(i, length)
}
}
}
let start = clamp(start, 0)
let stop = clamp(stop, length)
let lines = self.divide([start, stop])
lines.get(1)
}
///|
/// The number of cells required to render this text.
pub fn Text::cell_len(self : Text) -> Int {
@cells.cell_len(self.plain())
}
///|
/// Console markup that renders this Text.
pub fn Text::markup(self : Text) -> String {
let output = []
let plain = self.plain()
let markup_spans : Array[(Int, Bool, StyleType, Int)] = []
let mut order = 0
fn add(offset : Int, closing : Bool, style : StyleType) {
markup_spans.push((offset, closing, style, order))
order += 1
}
add(0, false, self.style)
for span in self.spans_ {
add(span.start, false, span.style)
}
for span in self.spans_ {
add(span.end, true, span.style)
}
add(plain.length(), true, self.style)
stable_sort_by(markup_spans, (a, b) => {
let c = a.0.compare(b.0)
if c != 0 {
c
} else {
a.1.compare(b.1)
}
})
let mut position = 0
for item in markup_spans {
let (offset, closing, style, _) = item
if offset > position {
output.push(
escape(plain.view(start_offset=position, end_offset=offset).to_owned()),
)
position = offset
}
if style.to_bool() {
output.push(if closing { "[/\{style}]" } else { "[\{style}]" })
}
}
output.join("")
}
///|
/// Stable sort (insertion sort for short arrays, merge sort otherwise).
fn[T] stable_sort_by(xs : Array[T], cmp : (T, T) -> Int) -> Unit {
let n = xs.length()
if n < 2 {
return
}
if n <= 16 {
for i in 1..= 0 && cmp(xs[j], x) > 0 {
xs[j + 1] = xs[j]
j -= 1
}
xs[j + 1] = x
}
return
}
let mid = n / 2
let left = xs[0:mid].to_owned()
let right = xs[mid:n].to_owned()
stable_sort_by(left, cmp)
stable_sort_by(right, cmp)
let mut i = 0
let mut j = 0
let mut k = 0
while i < left.length() && j < right.length() {
if cmp(right[j], left[i]) < 0 {
xs[k] = right[j]
j += 1
} else {
xs[k] = left[i]
i += 1
}
k += 1
}
while i < left.length() {
xs[k] = left[i]
i += 1
k += 1
}
while j < right.length() {
xs[k] = right[j]
j += 1
k += 1
}
}
///|
/// Create a Text instance from console markup.
pub fn Text::from_markup(
text : String,
style? : &IntoStyle,
emoji? : Bool = true,
emoji_variant? : EmojiVariant,
justify? : JustifyMethod,
overflow? : OverflowMethod,
end? : String = "\n",
) -> Text raise RichError {
let rendered = render_markup(
text,
style=style_or_empty(style),
emoji~,
emoji_variant?,
)
rendered.justify = justify
rendered.overflow = overflow
rendered.end = end
rendered
}
///|
/// Create a Text instance from a string containing ANSI escape codes.
pub fn Text::from_ansi(
text : String,
style? : &IntoStyle,
justify? : JustifyMethod,
overflow? : OverflowMethod,
no_wrap? : Bool,
end? : String = "\n",
tab_size? : Int = 8,
) -> Text {
let joiner = Text::new(
text="\n",
justify?,
overflow?,
no_wrap?,
end~,
tab_size~,
style=style_or_empty(style),
)
let decoder = AnsiDecoder::new()
joiner.join(decoder.decode(text))
}
///|
/// Construct a Text instance with a pre-applied style. A style applied in
/// this way won't be used to pad the text when it is justified.
pub fn Text::styled(
text : String,
style? : &IntoStyle,
justify? : JustifyMethod,
overflow? : OverflowMethod,
) -> Text {
let styled_text = Text::new(text~, justify?, overflow?)
styled_text.stylize(style_or_empty(style))
styled_text
}
///|
/// A part for `Text::assemble`: a string, a Text, or a `(string, style)`
/// tuple.
pub(open) trait TextPart {
fn append_to(Self, Text) -> Unit
}
///|
pub impl TextPart for String with fn append_to(self, text) {
text.append_str(self) |> ignore
}
///|
pub impl TextPart for Text with fn append_to(self, text) {
if self.length_ != 0 {
text.append_text(self) |> ignore
}
}
///|
pub impl[S : IntoStyle] TextPart for (String, S) with fn append_to(self, text) {
text.append_str(self.0, style=self.1.into_style_type()) |> ignore
}
///|
/// Construct a text instance by combining strings, Texts and
/// `(string, style)` tuples.
pub fn Text::assemble(
parts : ArrayView[&TextPart],
style? : &IntoStyle,
justify? : JustifyMethod,
overflow? : OverflowMethod,
no_wrap? : Bool,
end? : String = "\n",
tab_size? : Int = 8,
meta? : Map[String, Json],
) -> Text {
let text = Text::new(
style=style_or_empty(style),
justify?,
overflow?,
no_wrap?,
end~,
tab_size~,
)
for part in parts {
part.append_to(text)
}
if meta is Some(meta) && !meta.is_empty() {
text.apply_meta(meta)
}
text
}
///|
/// The spans (a reference to the internal list).
pub fn Text::spans(self : Text) -> Array[Span] {
self.spans_
}
///|
/// Replace the spans (copied).
pub fn Text::set_spans(self : Text, spans : Array[Span]) -> Unit {
self.spans_ = spans.copy()
}
///|
/// A new Text instance with copied metadata (but not the string or spans).
pub fn Text::blank_copy(self : Text, plain? : String = "") -> Text {
let plain = strip_control_codes(plain)
{
parts: [plain],
length_: plain.length(),
spans_: [],
style: self.style,
justify: self.justify,
overflow: self.overflow,
no_wrap: self.no_wrap,
end: self.end,
tab_size: self.tab_size,
}
}
///|
/// A copy of this instance.
pub fn Text::copy(self : Text) -> Text {
let copy = self.blank_copy(plain=self.plain())
copy.spans_ = self.spans_.copy()
copy
}
///|
fn Text::normalize_range(self : Text, start : Int, end : Int?) -> (Int, Int)? {
let length = self.length_
let start = if start < 0 { length + start } else { start }
let end = match end {
None => length
Some(e) => if e < 0 { length + e } else { e }
}
if start >= length || end <= start {
None
} else {
Some((start, @cmp.minimum(length, end)))
}
}
///|
/// Apply a style to the text, or a portion of the text (negative offsets
/// count from the end).
pub fn Text::stylize(
self : Text,
style : &IntoStyle,
start? : Int = 0,
end? : Int,
) -> Unit {
let style = style.into_style_type()
if style.to_bool() {
if self.normalize_range(start, end) is Some((start, end)) {
self.spans_.push({ start, end, style, })
}
}
}
///|
/// Apply a style before other styles already present.
pub fn Text::stylize_before(
self : Text,
style : &IntoStyle,
start? : Int = 0,
end? : Int,
) -> Unit {
let style = style.into_style_type()
if style.to_bool() {
if self.normalize_range(start, end) is Some((start, end)) {
self.spans_.insert(0, { start, end, style, })
}
}
}
///|
/// Apply metadata to the text, or a portion of the text.
pub fn Text::apply_meta(
self : Text,
meta : Map[String, Json],
start? : Int = 0,
end? : Int,
) -> Unit {
let style = Style::from_meta(Some(meta))
self.stylize(style, start~, end?)
}
///|
/// Apply event handlers (used by Textual). Each handler key is prefixed
/// with "@".
pub fn Text::on(
self : Text,
meta? : Map[String, Json],
handlers? : Map[String, Json],
) -> Text {
self.stylize(Style::on(meta?, handlers?))
self
}
///|
/// Remove a suffix if it exists.
pub fn Text::remove_suffix(self : Text, suffix : String) -> Unit {
if self.plain().has_suffix(suffix) {
self.right_crop(amount=suffix.length())
}
}
///|
/// Get the style of a character at the given offset.
pub fn Text::get_style_at_offset(
self : Text,
console : Console,
offset : Int,
) -> Style raise {
let offset = if offset < 0 { self.length_ + offset } else { offset }
let mut style = console.get_style(self.style).copy_value()
for span in self.spans_ {
if span.end > offset && offset >= span.start {
style = style + console.get_style(span.style, default=Def(""))
}
}
style
}
///|
/// Extend the Text given number of spaces where the spaces have the same
/// style as the last character.
pub fn Text::extend_style(self : Text, spaces : Int) -> Unit {
if spaces <= 0 {
return
}
let new_spaces = space_string(spaces)
if !self.spans_.is_empty() {
let end_offset = self.length_
self.spans_ = self.spans_.map(span => {
if span.end >= end_offset {
span.extend_(spaces)
} else {
span
}
})
self.parts.push(new_spaces)
self.length_ += spaces
} else {
self.set_plain(self.plain() + new_spaces)
}
}
///|
let regex_cache : @memo.Memo[(String, Int), @regex.Regex] = @memo.Memo::new(512)
///|
/// Compile a regular expression (Python `re` syntax), with caching.
pub fn compile_regex(
pattern : String,
flags? : Int = 0,
) -> @regex.Regex raise @regex.RegexError {
let key = (pattern, flags)
match regex_cache.get(key) {
Some(re) => re
None => {
let re = @regex.compile(pattern, flags~)
regex_cache.set(key, re)
re
}
}
}
///|
/// Highlight text with a regular expression, where group names are
/// translated to styles. `style` applies to the whole match; `style_fn`
/// computes the whole-match style from the matched text. Returns the number
/// of matches.
pub fn Text::highlight_regex(
self : Text,
re_highlight : @regex.Regex,
style? : &IntoStyle,
style_fn? : (String) -> StyleType?,
style_prefix? : String = "",
) -> Int raise @regex.RegexError {
let mut count = 0
let plain = self.plain()
let names = re_highlight.group_names()
let style = style_opt(style)
for m in re_highlight.find_all(plain) {
let start = m.start()
let end = m.end()
let match_style = match (style, style_fn) {
(_, Some(f)) =>
f(plain.view(start_offset=start, end_offset=end).to_owned())
(Some(s), None) => if s.to_bool() { Some(s) } else { None }
(None, None) => None
}
if match_style is Some(s) && end > start {
self.spans_.push({ start, end, style: s, })
}
count += 1
for name_index in names {
let (name, index) = name_index
let start = m.start(group=index)
let end = m.end(group=index)
if start != -1 && end > start {
self.spans_.push({ start, end, style: Def(style_prefix + name), })
}
}
}
count
}
///|
/// Like `highlight_regex`, compiling `pattern` first.
pub fn Text::highlight_pattern(
self : Text,
pattern : String,
style? : &IntoStyle,
style_prefix? : String = "",
) -> Int raise @regex.RegexError {
self.highlight_regex(compile_regex(pattern), style?, style_prefix~)
}
///|
/// Python `re.escape`.
pub fn regex_escape(s : String) -> String {
let buf = StringBuilder()
for c in s {
let n = c.to_int()
let special = n < 128 && !(c is ('a'..='z' | 'A'..='Z' | '0'..='9' | '_'))
if special {
buf.write_char('\\')
}
buf.write_char(c)
}
buf.to_string()
}
///|
/// Highlight words with a style. Returns the number of words highlighted.
pub fn Text::highlight_words(
self : Text,
words : ArrayView[String],
style : &IntoStyle,
case_sensitive? : Bool = true,
) -> Int raise @regex.RegexError {
let re_words = words.map(regex_escape).join("|")
let style = style.into_style_type()
let re = compile_regex(
re_words,
flags=if case_sensitive { 0 } else { @regex.IGNORECASE },
)
let mut count = 0
for m in re.find_all(self.plain()) {
self.spans_.push({ start: m.start(), end: m.end(), style, })
count += 1
}
count
}
///|
/// Python `str.rstrip()`.
fn py_rstrip(s : String) -> String {
let mut end = s.length()
while end > 0 && is_py_whitespace(s[end - 1].to_int().unsafe_to_char()) {
end -= 1
}
if end == s.length() {
s
} else {
s.view(end_offset=end).to_owned()
}
}
///|
/// Strip whitespace from end of text.
pub fn Text::rstrip(self : Text) -> Unit {
self.set_plain(py_rstrip(self.plain()))
}
///|
/// Remove whitespace beyond a certain width at the end of the text.
pub fn Text::rstrip_end(self : Text, size : Int) -> Unit {
// upstream compares the length in code points with `size` (in cells);
// there are never more code points than UTF-16 code units
if self.length_ <= size {
return
}
let text_length = self.plain().char_length()
if text_length > size {
let excess = text_length - size
let plain = self.plain()
let mut whitespace_count = 0
while whitespace_count < plain.length() &&
is_py_whitespace(
plain[plain.length() - 1 - whitespace_count]
.to_int()
.unsafe_to_char(),
) {
whitespace_count += 1
}
if whitespace_count > 0 {
self.right_crop(amount=@cmp.minimum(whitespace_count, excess))
}
}
}
///|
/// Set new length of the text, clipping or padding as required.
pub fn Text::set_length(self : Text, new_length : Int) -> Unit {
let length = self.length_
if length != new_length {
if length < new_length {
self.pad_right(new_length - length)
} else {
self.right_crop(amount=length - new_length)
}
}
}
///|
pub impl Renderable for Text with fn rich_console(self, console, options) {
let out = []
self.render_console_into(console, options, out)
segments_to_items(out)
}
///|
/// `rich_console` appending the segments to `out` (used by the console's
/// render loop to avoid wrapping every segment in a `RenderItem`).
fn Text::render_console_into(
self : Text,
console : Console,
options : ConsoleOptions,
out : Array[Segment],
) -> Unit raise {
let tab_size = match self.tab_size {
None => console.tab_size
Some(t) => t
}
let justify = self.justify.unwrap_or(options.justify.unwrap_or(Default))
let overflow = self.overflow.unwrap_or(options.overflow.unwrap_or(Fold))
let lines = self.wrap(
console,
options.max_width,
justify~,
overflow~,
tab_size=if tab_size == 0 { 8 } else { tab_size },
no_wrap=pick_bool([self.no_wrap, options.no_wrap, Some(false)]),
)
render_joined_lines(lines.lines, console, self.end, out)
}
///|
pub impl Renderable for Text with fn rich_measure(self, _console, _options) {
let text = self.plain()
let n = text.length()
// widest line (Python `str.splitlines()`)
let mut max_text_width = 0
let mut start = 0
let mut i = 0
while i < n {
let c = text.unsafe_get(i).to_int()
if is_line_break_unit(c) {
let width = @cells.cell_len(text.view(start_offset=start, end_offset=i))
if width > max_text_width {
max_text_width = width
}
if c == 0x0d && i + 1 < n && text.unsafe_get(i + 1).to_int() == 0x0a {
i += 1
}
i += 1
start = i
} else {
i += 1
}
}
if start < n {
let width = @cells.cell_len(text.view(start_offset=start, end_offset=n))
if width > max_text_width {
max_text_width = width
}
}
// widest word (Python `str.split()`)
let mut has_words = false
let mut min_text_width = 0
let mut i = 0
while i < n {
while i < n && is_py_space_unit(text.unsafe_get(i).to_int()) {
i += 1
}
let word_start = i
while i < n && !is_py_space_unit(text.unsafe_get(i).to_int()) {
i += 1
}
if i > word_start {
has_words = true
let width = @cells.cell_len(
text.view(start_offset=word_start, end_offset=i),
)
if width > min_text_width {
min_text_width = width
}
}
}
if !has_words {
min_text_width = max_text_width
}
Some({ minimum: min_text_width, maximum: max_text_width, })
}
///|
pub impl Renderable for Text with fn text_kind(self) {
Text(self)
}
///|
/// Python `str.splitlines()` (without keepends).
fn py_splitlines(s : String) -> Array[String] {
let lines = []
for_each_splitline(s, (start, end) => {
lines.push(s.unsafe_substring(start~, end~))
})
lines
}
///|
/// Line boundaries of Python's `str.splitlines()` (all in the BMP).
fn is_line_break_unit(c : Int) -> Bool {
c == 0x0a ||
c == 0x0d ||
c == 0x0b ||
c == 0x0c ||
c == 0x1c ||
c == 0x1d ||
c == 0x1e ||
c == 0x85 ||
c == 0x2028 ||
c == 0x2029
}
///|
/// Call `f(start, end)` for each line of `s` as split by Python's
/// `str.splitlines()`.
fn for_each_splitline(s : String, f : (Int, Int) -> Unit) -> Unit {
// see also `rich_measure`, which inlines this loop
let n = s.length()
let mut start = 0
let mut i = 0
while i < n {
let c = s.unsafe_get(i).to_int()
if is_line_break_unit(c) {
f(start, i)
if c == 0x0d && i + 1 < n && s.unsafe_get(i + 1).to_int() == 0x0a {
i += 1
}
i += 1
start = i
} else {
i += 1
}
}
if start < n {
f(start, n)
}
}
///|
/// Render the text as Segments.
pub fn Text::render(
self : Text,
console : Console,
end? : String = "",
) -> Array[Segment] raise {
let result = []
self.render_into(console, end, result)
result
}
///|
/// Join texts together with this instance as the separator.
pub fn Text::join(self : Text, lines : ArrayView[Text]) -> Text {
let new_text = self.blank_copy()
let items : Array[Text] = []
if self.plain() != "" {
for i, line in lines {
items.push(line)
if i != lines.length() - 1 {
items.push(self)
}
}
} else {
for line in lines {
items.push(line)
}
}
let mut offset = 0
let new_parts = []
for text in items {
new_parts.push(text.plain())
if text.style.to_bool() {
new_text.spans_.push({
start: offset,
end: offset + text.length_,
style: text.style,
})
}
for span in text.spans_ {
new_text.spans_.push({
start: offset + span.start,
end: offset + span.end,
style: span.style,
})
}
offset += text.length_
}
new_text.parts.append(new_parts)
new_text.length_ = offset
new_text
}
///|
/// Convert tabs to spaces.
pub fn Text::expand_tabs(self : Text, tab_size? : Int) -> Unit {
if !self.plain().contains("\t") {
return
}
let tab_size = match tab_size {
Some(t) => t
None => self.tab_size.unwrap_or(8)
}
let new_text : Array[Text] = []
for line in self.split(separator="\n", include_separator=true).lines {
if !line.plain().contains("\t") {
new_text.push(line)
} else {
let mut cell_position = 0
let parts = line.split(separator="\t", include_separator=true)
for part in parts.lines {
if part.plain().has_suffix("\t") {
let last = part.parts[part.parts.length() - 1]
part.parts[part.parts.length() - 1] = last
.strip_suffix("\t")
.unwrap_or(last[:])
.to_owned() +
" "
cell_position += part.cell_len()
let tab_remainder = py_mod(cell_position, tab_size)
if tab_remainder != 0 {
let spaces = tab_size - tab_remainder
part.extend_style(spaces)
cell_position += spaces
}
} else {
cell_position += part.cell_len()
}
new_text.push(part)
}
}
}
let result = Text::new().join(new_text)
self.parts = [result.plain()]
self.length_ = self.plain().length()
self.spans_ = result.spans_.copy()
}
///|
/// Truncate text if it is longer than a given width.
pub fn Text::truncate(
self : Text,
max_width : Int,
overflow? : OverflowMethod,
pad? : Bool = false,
) -> Unit {
let overflow = overflow.unwrap_or(self.overflow.unwrap_or(Fold))
if overflow != Ignore {
let length = @cells.cell_len(self.plain())
if length > max_width {
if overflow == Ellipsis {
self.set_plain(
@cells.set_cell_size(self.plain(), max_width - 1) + "…",
)
} else {
self.set_plain(@cells.set_cell_size(self.plain(), max_width))
}
}
if pad && length < max_width {
let spaces = max_width - length
self.parts = [self.plain() + space_string(spaces)]
self.length_ = self.plain().length()
}
}
}
///|
/// Remove or modify any spans that are over the end of the text.
fn Text::trim_spans(self : Text) -> Unit {
let max_offset = self.plain().length()
let spans = []
for span in self.spans_ {
if span.start < max_offset {
spans.push(
if span.end < max_offset {
span
} else {
{
start: span.start,
end: @cmp.minimum(max_offset, span.end),
style: span.style,
}
},
)
}
}
self.spans_ = spans
}
///|
/// Pad left and right with a given number of characters.
pub fn Text::pad(self : Text, count : Int, character? : Char = ' ') -> Unit {
if count != 0 {
let pad_characters = py_char_repeat(character, count)
self.set_plain(pad_characters + self.plain() + pad_characters)
let shift = count * char_utf16_len(character)
self.spans_ = self.spans_.map(span => span.move_(shift))
}
}
///|
/// Python `character * count` (empty for negative counts).
fn py_char_repeat(character : Char, count : Int) -> String {
if count <= 0 {
""
} else {
String::make(count, character)
}
}
///|
fn char_utf16_len(c : Char) -> Int {
if c.to_int() > 0xFFFF {
2
} else {
1
}
}
///|
/// Pad the left with a given character. As upstream, a negative `count`
/// adds nothing but still moves the spans.
pub fn Text::pad_left(
self : Text,
count : Int,
character? : Char = ' ',
) -> Unit {
if count != 0 {
self.set_plain(py_char_repeat(character, count) + self.plain())
let shift = count * char_utf16_len(character)
self.spans_ = self.spans_.map(span => span.move_(shift))
}
}
///|
/// Pad the right with a given character.
pub fn Text::pad_right(
self : Text,
count : Int,
character? : Char = ' ',
) -> Unit {
if count != 0 {
self.set_plain(self.plain() + py_char_repeat(character, count))
}
}
///|
/// Align text to a given width.
pub fn Text::align(
self : Text,
align : AlignMethod,
width : Int,
character? : Char = ' ',
) -> Unit {
self.truncate(width)
let excess_space = width - @cells.cell_len(self.plain())
if excess_space != 0 {
match align {
Left => self.pad_right(excess_space, character~)
Center => {
let left = floor_div(excess_space, 2)
self.pad_left(left, character~)
self.pad_right(excess_space - left, character~)
}
Right => self.pad_left(excess_space, character~)
}
}
}
///|
/// Append a string with an optional style.
pub fn Text::append_str(
self : Text,
text : String,
style? : &IntoStyle,
) -> Text {
if text.length() != 0 {
let sanitized = strip_control_codes(text)
self.parts.push(sanitized)
let offset = self.length_
let text_length = sanitized.length()
match style_opt(style) {
Some(s) if s.to_bool() =>
self.spans_.push({ start: offset, end: offset + text_length, style: s, })
_ => ()
}
self.length_ += text_length
}
self
}
///|
/// Append a string or Text, with an optional style (only for strings).
pub fn Text::append(
self : Text,
text : &IntoText,
style? : &IntoStyle,
) -> Text raise RichError {
match text.into_text_type() {
Str(s) => self.append_str(s, style?)
Text(t) => {
if t.length_ == 0 {
return self
}
if style is Some(_) {
raise ValueError("style must not be set when appending Text instance")
}
self.append_text(t)
}
}
}
///|
/// Append another Text instance. Unlike `append`, an empty Text with a
/// style adds an empty span (as upstream).
pub fn Text::append_text(self : Text, text : Text) -> Text {
let text_length = self.length_
if text.style.to_bool() {
self.spans_.push({
start: text_length,
end: text_length + text.length_,
style: text.style,
})
}
self.parts.push(text.plain())
for span in text.spans_.copy() {
self.spans_.push({
start: span.start + text_length,
end: span.end + text_length,
style: span.style,
})
}
self.length_ += text.length_
self
}
///|
/// Append `(content, style)` tokens.
pub fn Text::append_tokens(
self : Text,
tokens : ArrayView[(String, StyleType?)],
) -> Text {
let mut offset = self.length_
for token in tokens {
let content = strip_control_codes(token.0)
self.parts.push(content)
if token.1 is Some(style) && style.to_bool() {
self.spans_.push({ start: offset, end: offset + content.length(), style, })
}
offset += content.length()
}
self.length_ = offset
self
}
///|
/// Copy styles from another Text instance (must be the same length).
pub fn Text::copy_styles(self : Text, text : Text) -> Unit {
self.spans_.append(text.spans_)
}
///|
fn find_all_str(text : String, sep : String) -> Array[(Int, Int)] {
let out = []
let mut pos = 0
while pos <= text.length() {
match text.view(start_offset=pos).find(sep) {
Some(i) => {
let start = pos + i
out.push((start, start + sep.length()))
pos = start + sep.length()
}
None => break
}
}
out
}
///|
/// Split rich text in to lines, preserving styles.
pub fn Text::split(
self : Text,
separator? : String = "\n",
include_separator? : Bool = false,
allow_blank? : Bool = false,
) -> Lines {
guard separator != "" else { abort("separator must not be empty") }
let text = self.plain()
if !text.contains(separator) {
return Lines::new([self.copy()])
}
let matches = find_all_str(text, separator)
let lines = if include_separator {
self.divide(matches.map(m => m.1))
} else {
let offsets = []
for m in matches {
offsets.push(m.0)
offsets.push(m.1)
}
Lines::new(
self.divide(offsets).lines.filter(line => line.plain() != separator),
)
}
if !allow_blank && text.has_suffix(separator) {
lines.pop() |> ignore
}
lines
}
///|
/// Divide text into a number of lines at given offsets.
pub fn Text::divide(self : Text, offsets : ArrayView[Int]) -> Lines {
if offsets.length() == 0 {
return Lines::new([self.copy()])
}
let text = self.plain()
let text_length = text.length()
// line `i` covers `bounds[i]..bounds[i + 1]` (upstream `line_ranges`)
let line_count = offsets.length() + 1
let bounds : FixedArray[Int] = FixedArray::make(line_count + 1, 0)
for i, o in offsets {
bounds[i + 1] = o
}
bounds[line_count] = text_length
let lines : Array[Text] = Array(capacity=line_count)
for i in 0.. line_end {
lower_bound = start_line_no + 1
} else {
break
}
start_line_no = floor_div(lower_bound + upper_bound, 2)
}
let end_line_no = if span_end < line_end {
start_line_no
} else {
let mut end_line_no = start_line_no
lower_bound = start_line_no
upper_bound = line_count
while true {
let line_start = bounds[end_line_no]
let le = bounds[end_line_no + 1]
if span_end < line_start {
upper_bound = end_line_no - 1
} else if span_end > le {
lower_bound = end_line_no + 1
} else {
break
}
end_line_no = floor_div(lower_bound + upper_bound, 2)
}
end_line_no
}
for line_no in start_line_no..<=end_line_no {
let line_start = bounds[line_no]
let line_end = bounds[line_no + 1]
let new_start = @cmp.maximum(0, span_start - line_start)
let new_end = @cmp.minimum(span_end - line_start, line_end - line_start)
if new_end > new_start {
lines[line_no].spans_.push({
start: new_start,
end: new_end,
style: span.style,
})
}
}
}
new_lines
}
///|
/// Python `s[a:b]` (with clamping of out-of-range and negative indices).
fn py_slice(s : String, a : Int, b : Int) -> String {
let n = s.length()
let a = if a < 0 { @cmp.maximum(0, n + a) } else { @cmp.minimum(a, n) }
let b = if b < 0 { @cmp.maximum(0, n + b) } else { @cmp.minimum(b, n) }
if a >= b {
""
} else {
s.view(start_offset=a, end_offset=b).to_owned()
}
}
///|
/// Remove a number of characters from the end of the text.
pub fn Text::right_crop(self : Text, amount? : Int = 1) -> Unit {
let plain = self.plain()
let max_offset = plain.length() - amount
let spans = []
for span in self.spans_ {
if span.start < max_offset {
spans.push(
if span.end < max_offset {
span
} else {
{
start: span.start,
end: @cmp.minimum(max_offset, span.end),
style: span.style,
}
},
)
}
}
self.spans_ = spans
// Python `plain[:-amount]` (amount 0 gives an empty string)
self.parts = [py_slice(plain, 0, -amount)]
self.length_ -= amount
}
///|
/// Word wrap the text.
pub fn Text::wrap(
self : Text,
console : Console,
width : Int,
justify? : JustifyMethod,
overflow? : OverflowMethod,
tab_size? : Int = 8,
no_wrap? : Bool,
) -> Lines raise {
let wrap_justify = justify.unwrap_or(self.justify.unwrap_or(Default))
let wrap_overflow = overflow.unwrap_or(self.overflow.unwrap_or(Fold))
let no_wrap = pick_bool([no_wrap, self.no_wrap, Some(false)]) ||
overflow is Some(Ignore)
let lines = Lines::new([])
for line in self.split(allow_blank=true).lines {
if line.plain().contains("\t") {
line.expand_tabs(tab_size~)
}
let new_lines = if no_wrap {
if overflow is Some(Ignore) {
lines.append(line)
continue
}
Lines::new([line])
} else {
let offsets = divide_line(line.plain(), width, fold=wrap_overflow == Fold)
// `line` is a fresh copy from `split`: no need to copy it again
let new_lines = if offsets.is_empty() {
Lines::new([line])
} else {
line.divide(offsets)
}
for l in new_lines.lines {
l.rstrip_end(width)
}
new_lines
}
new_lines.justify(
console,
width,
justify=wrap_justify,
overflow=wrap_overflow,
)
for l in new_lines.lines {
l.truncate(width, overflow=wrap_overflow)
}
lines.extend_(new_lines.lines)
}
lines
}
///|
/// Fit the text in to given width by chopping in to lines.
pub fn Text::fit(self : Text, width : Int) -> Lines {
let lines = Lines::new([])
for line in self.split().lines {
line.set_length(width)
lines.append(line)
}
lines
}
///|
fn gcd(a : Int, b : Int) -> Int {
let mut a = if a < 0 { -a } else { a }
let mut b = if b < 0 { -b } else { b }
while b != 0 {
let t = a % b
a = b
b = t
}
a
}
///|
/// Auto-detect indentation of code.
pub fn Text::detect_indentation(self : Text) -> Int {
let indentations : Map[Int, Unit] = Map([])
for line in self.plain().split("\n") {
let mut n = 0
while n < line.length() && line[n] == ' ' {
n += 1
}
indentations[n] = ()
}
let evens = []
for indent, _ in indentations {
if indent % 2 == 0 {
evens.push(indent)
}
}
if evens.is_empty() {
return 1
}
let mut result = evens[0]
for i in 1.. Int {
let mut offset = 0
let mut count = 0
while count < n && offset < s.length() {
let c = s[offset].to_int()
offset += if c >= 0xD800 && c <= 0xDBFF && offset + 1 < s.length() {
2
} else {
1
}
count += 1
}
offset
}
///|
/// Add indent guide lines to text.
pub fn Text::with_indent_guides(
self : Text,
indent_size? : Int,
character? : String = "│",
style? : &IntoStyle,
) -> Text {
let style = match style {
Some(s) => s.into_style_type()
None => Def("dim green")
}
let indent_size = match indent_size {
None => self.detect_indentation()
Some(i) => i
}
let text = self.copy()
text.expand_tabs()
let indent_line = character + space_string(indent_size - 1)
let new_lines : Array[Text] = []
let mut blank_lines = 0
for line in text.split(allow_blank=true).lines {
let plain = line.plain()
let mut n = 0
while n < plain.length() && plain[n] == ' ' {
n += 1
}
if n == plain.length() {
blank_lines += 1
continue
}
let full_indents = n / indent_size
let remaining_space = n % indent_size
let new_indent = indent_line.repeat(full_indents) +
space_string(remaining_space)
// upstream slices the line by the (code point) length of the new indent
let skip = utf16_offset_of_codepoint(plain, new_indent.char_length())
line.set_plain(new_indent + py_slice(plain, skip, plain.length()))
line.stylize(style, start=0, end=new_indent.length())
if blank_lines != 0 {
for _ in 0..