// Copyright 2026 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 . This file may not be
// copied, modified, or distributed except according to those terms.
///|
/// A legal boundary in a `DisplayText`.
///
/// Values are produced by `DisplayText` methods. They are intentionally opaque
/// so callers cannot accidentally point into the middle of a display unit.
struct TextualPosition(Int) derive(Eq, Debug)
///|
/// A display-column position in a single `DisplayText`.
struct DisplayPosition(Int) derive(Eq, Debug)
///|
/// Build a single-line display position. Negative columns are clamped to zero.
pub fn DisplayPosition::new(column~ : Int) -> DisplayPosition {
DisplayPosition(column.max(0))
}
///|
/// The zero-based terminal display column.
pub fn DisplayPosition::column(self : DisplayPosition) -> Int {
self.0
}
///|
/// A terminal display unit with stable textual and display boundaries.
///
/// Textual boundaries are legal positions in the original line. Display
/// boundaries are terminal display columns. Both ranges are half-open:
/// `[start, end)`.
///
/// For example, `split_lines("aไฝ ๅฅฝb")[0]` has these units:
///
/// ```text
/// textual: 0 1 2 3 4
/// | "a" | "ไฝ " | "ๅฅฝ" | "b" |
/// display: 0 1 3 5 6
/// ```
///
/// A ZWJ emoji sequence is one unit even when it is made from several emoji
/// joined together. For `split_lines("x๐จโ๐ฉโ๐งโ๐ฆy")[0]`:
///
/// ```text
/// textual: 0 1 2 3
/// | "x" | "๐จโ๐ฉโ๐งโ๐ฆ" | "y" |
/// display: 0 1 3 4
/// ```
///
/// A zero-width unit has the same display start and end. For
/// `split_lines("a\u{200B}b")[0]`:
///
/// ```text
/// textual: 0 1 2 3
/// | "a" | "\u{200B}" | "b" |
/// display: 0 1 1 2
/// ```
priv struct DisplayUnit {
text : StringView
width : Int
textual_end : TextualPosition
display_end : DisplayPosition
} derive(Debug)
///|
fn DisplayUnit::text(self : DisplayUnit) -> StringView {
self.text
}
///|
fn DisplayUnit::width(self : DisplayUnit) -> Int {
self.width
}
///|
fn DisplayUnit::textual_end(self : DisplayUnit) -> TextualPosition {
self.textual_end
}
///|
fn DisplayUnit::display_end(self : DisplayUnit) -> DisplayPosition {
self.display_end
}
///|
/// A single text run parsed into terminal display units.
struct DisplayText {
text : String
units : Array[DisplayUnit]
width : Int
cjk : Bool
} derive(Debug)
///|
pub impl Eq for DisplayText with fn equal(self, other) {
self.text == other.text
}
///|
fn make_display_unit(
text : StringView,
width : Int,
textual_index : Int,
display_column : Int,
) -> DisplayUnit {
{
text,
width,
textual_end: TextualPosition(textual_index + 1),
display_end: DisplayPosition(display_column + width),
}
}
///|
fn flush_pending_unit(
units : Array[DisplayUnit],
source : String,
start : Int,
end : Int,
width : Int,
display_column : Int,
) -> Int {
units.push(
make_display_unit(
source.view(start_offset=start, end_offset=end),
width,
units.length(),
display_column,
),
)
display_column + width
}
///|
fn single_unit_line(source : String, width : Int, cjk : Bool) -> DisplayText {
if source.length() == 0 {
{ text: source, units: [], width, cjk }
} else {
{
text: source,
units: [make_display_unit(source.view(), width, 0, 0)],
width,
cjk,
}
}
}
///|
fn display_units_for(source : String, cjk : Bool) -> Array[DisplayUnit] {
let units : Array[DisplayUnit] = []
let mut display_column = 0
let mut pending_start = 0
let mut pending_end = 0
let mut pending_width = 0
let mut has_pending = false
for grapheme in @grapheme.grapheme_iter(source) {
let grapheme_width = @unicodewidth.str_width(grapheme, cjk~)
if !has_pending {
pending_start = grapheme.start_offset()
pending_end = grapheme.start_offset() + grapheme.length()
pending_width = grapheme_width
has_pending = true
} else {
let next_end = grapheme.start_offset() + grapheme.length()
let combined_width = @unicodewidth.str_width(
source.view(start_offset=pending_start, end_offset=next_end),
cjk~,
)
if combined_width == pending_width + grapheme_width {
display_column = flush_pending_unit(
units, source, pending_start, pending_end, pending_width, display_column,
)
pending_start = grapheme.start_offset()
pending_end = next_end
pending_width = grapheme_width
} else {
pending_end = next_end
pending_width = combined_width
}
}
}
if has_pending {
ignore(
flush_pending_unit(
units, source, pending_start, pending_end, pending_width, display_column,
),
)
}
units
}
///|
fn make_display_text(source : String, cjk : Bool) -> DisplayText {
let width = @unicodewidth.str_width(source, cjk~)
let units = display_units_for(source, cjk)
let mut unit_width = 0
for unit in units {
unit_width += unit.width()
}
if unit_width == width {
{ text: source, units, width, cjk }
} else {
single_unit_line(source, width, cjk)
}
}
///|
/// Parse `text` as one terminal display text run.
///
/// This constructor does not split hard line breaks. If `text` contains `\n`,
/// `\r\n`, or `\r`, they remain part of this single `DisplayText`. Use
/// `split_lines` when the input may contain multiple hard lines.
#alias(new)
pub fn DisplayText::DisplayText(
text : String,
cjk? : Bool = false,
) -> DisplayText {
make_display_text(text, cjk)
}
///|
/// Return the original text owned by this display text run.
pub fn DisplayText::text(self : DisplayText) -> String {
self.text
}
///|
/// Split `text` into hard-line display text runs.
///
/// Hard line breaks (`\n`, `\r\n`, and `\r`) are not included in the returned
/// lines. Empty lines are preserved, including the trailing empty line after a
/// final line break. Each returned `DisplayText` exposes safe textual
/// boundaries and display columns for TUI editing, truncation, and hit testing.
pub fn split_lines(
text : StringView,
cjk? : Bool = false,
) -> Array[DisplayText] {
let source = text.to_owned()
let lines : Array[DisplayText] = []
let mut current = StringBuilder()
for grapheme in @grapheme.grapheme_iter(source) {
let g = grapheme.to_owned()
if g == "\n" || g == "\r\n" || g == "\r" {
lines.push(DisplayText(current.to_string(), cjk~))
current = StringBuilder()
} else {
current.write_string(g)
}
}
lines.push(DisplayText(current.to_string(), cjk~))
lines
}
///|
/// Total display width of this text run in terminal cells.
pub fn DisplayText::width(self : DisplayText) -> Int {
self.width
}
///|
/// The first legal textual boundary in this text run.
pub fn DisplayText::start(self : DisplayText) -> TextualPosition {
ignore(self)
TextualPosition(0)
}
///|
/// The final legal textual boundary in this text run.
pub fn DisplayText::end(self : DisplayText) -> TextualPosition {
TextualPosition(self.units.length())
}
///|
fn DisplayText::position_index(
self : DisplayText,
position : TextualPosition,
) -> Int {
position.0.clamp(min=0, max=self.units.length())
}
///|
/// The next legal textual boundary after `position`, if any.
pub fn DisplayText::next(
self : DisplayText,
position : TextualPosition,
) -> TextualPosition? {
let index = self.position_index(position)
if index >= self.units.length() {
None
} else {
Some(TextualPosition(index + 1))
}
}
///|
/// The previous legal textual boundary before `position`, if any.
pub fn DisplayText::prev(
self : DisplayText,
position : TextualPosition,
) -> TextualPosition? {
let index = self.position_index(position)
if index <= 0 {
None
} else {
Some(TextualPosition(index - 1))
}
}
///|
/// Convert a legal textual boundary to its display-column position.
pub fn DisplayText::display_position(
self : DisplayText,
position : TextualPosition,
) -> DisplayPosition {
let index = self.position_index(position)
if index == 0 {
DisplayPosition(0)
} else {
self.units[index - 1].display_end()
}
}
///|
/// Return the nearest legal textual boundary at or before `position`.
pub fn DisplayText::textual_position_at_or_before(
self : DisplayText,
position : DisplayPosition,
) -> TextualPosition {
let column = position.column().clamp(min=0, max=self.width)
let mut result = self.start()
for unit in self.units {
if unit.display_end().column() <= column {
result = unit.textual_end()
} else {
return result
}
}
result
}
///|
/// Return the nearest legal textual boundary at or after `position`.
pub fn DisplayText::textual_position_at_or_after(
self : DisplayText,
position : DisplayPosition,
) -> TextualPosition {
let column = position.column().clamp(min=0, max=self.width)
if column <= 0 {
return self.start()
}
for unit in self.units {
if unit.display_end().column() >= column {
return unit.textual_end()
}
}
self.end()
}
///|
/// The UTF-16 code-unit offset of a legal textual boundary in the underlying
/// `text` (the same unit as `String::length`).
///
/// This is the textual analogue of `display_position`: where that projects a
/// boundary onto display columns, this projects it onto raw string offsets. It
/// is the inverse of `textual_position_at_char`.
pub fn DisplayText::char_offset(
self : DisplayText,
position : TextualPosition,
) -> Int {
let index = self.position_index(position)
if index >= self.units.length() {
self.text.length()
} else {
self.units[index].text().start_offset()
}
}
///|
/// The nearest legal textual boundary at or before `offset`, a UTF-16 code-unit
/// offset into the underlying `text` (the same unit as `String::length`).
///
/// When `offset` lands inside a display unit โ for example midway through a
/// non-additive grapheme run like an Arabic lam-alef ligature, where no legal
/// boundary exists โ it snaps back to the start of that unit. This is the char
/// analogue of `textual_position_at_or_before`, and the inverse of
/// `char_offset`. Out-of-range offsets are clamped to `[0, text length]`.
pub fn DisplayText::textual_position_at_char(
self : DisplayText,
offset~ : Int,
) -> TextualPosition {
let offset = offset.clamp(min=0, max=self.text.length())
// Boundaries are sorted by char offset (boundary 0 sits at 0; boundary k>0 at
// the end of unit k-1), so binary-search for the last boundary at or before
// `offset`. Boundary 0 always qualifies, so the answer is at least `start`.
let mut lo = 0
let mut hi = self.units.length()
while lo < hi {
let mid = (lo + hi + 1) / 2
let unit = self.units[mid - 1]
let mid_offset = unit.text().start_offset() + unit.text().length()
if mid_offset <= offset {
lo = mid
} else {
hi = mid - 1
}
}
TextualPosition(lo)
}
///|
/// View between two legal textual boundaries.
pub fn DisplayText::view(
self : DisplayText,
start : TextualPosition,
end : TextualPosition,
) -> StringView {
let start = self.position_index(start)
let end = self.position_index(end)
let start_offset = if start >= self.units.length() {
self.text.length()
} else {
self.units[start].text().start_offset()
}
let end_offset = if end <= start {
start_offset
} else {
let end_text = self.units[end - 1].text()
end_text.start_offset() + end_text.length()
}
self.text.view(start_offset~, end_offset~)
}
///|
/// Truncate this text run to `width` display cells, appending `suffix` when
/// content is dropped.
pub fn DisplayText::truncate(
self : DisplayText,
width : Int,
suffix? : StringView = "โฆ",
) -> String {
guard width > 0 else { return "" }
guard self.width > width else { return self.text }
let suffix_width = @unicodewidth.str_width(suffix, cjk=self.cjk)
guard suffix_width <= width else { return "" }
let content_width = width - suffix_width
let builder = StringBuilder()
let mut used = 0
for unit in self.units {
if used + unit.width() > content_width {
break
}
builder.write_stringview(unit.text())
used += unit.width()
}
builder.write_stringview(suffix)
builder.to_string()
}