Current section

Files

Jump to
string_width src string_width.gleam
Raw

src/string_width.gleam

//// <style>
//// small {
//// font-size: 0.7em;
//// opacity: 0.75;
//// }
//// </style>
////
//// The `with` variants of all functions additionally work with custom options.
////
//// **Tip:** Hover the links for short summaries!
////
//// #### Measure
//// [line](#line "Width of a line")<small>[[_with](#line_with)]</small>
//// [dimensions](#dimensions "Size of a string")<small>[[_with](#dimensions_with)]</small>,
//// [get_terminal_size](#get_terminal_size "Size of the terminal window")
////
//// #### Layout & Positioning
////
//// [limit](#limit "Word wrapping and truncation")<small>[[_with](#limit_with)]</small>,
//// [align](#align "Text alignment")<small>[[_with](#align_with)]</small>,
//// [tabs_to_spaces](#tabs_to_spaces "Pin tab widths")<small>[[_with](#tabs_to_spaces_with)]</small>
////
//// [stack_horizontal](#stack_horizontal "Column-based layouts")<small>[[_with](#stack_horizontal_with)]</small>,
//// [stack_vertical](#stack_vertical "Row-based layouts")<small>[[_with](#stack_vertical_with)]</small>,
//// [padding](#padding "Add extra space around a string")<small>[[_with](#padding_with)]</small>,
//// [position](#position "Put a string inside a fixed-size box")<small>[[_with](#position_with)]</small>,
//// [scroll](#scroll "Cutout a viewport area from a string")<small>[[_with](#scroll_with)]</small>
////
//// #### ANSI escape sequence helpers
//// [inline_styles](#inline_styles "Ensure styling doesn't overflow lines/columns"),
//// [strip_ansi](#strip_ansi "Remove all styles"),
//// [is_ansi_component](#is_ansi_component "Helper for fold")
////
//// #### Options Builder
//// [new](#new "Default options"),
//// [ambiguous_as_wide](#ambiguous_as_wide "CJK support"),
//// [count_ansi_codes](#count_ansi_codes "Non-terminal support"),
//// [mode_2027](#mode_2027 "Compatibility with other libraries"),
//// [mode_2027_ext](#mode_2027_ext "Compatibility with other libraries with extra salt"),
//// [mode_wcwidth](#mode_wcwidth "Default mode, use this for terminals!"),
//// [at_tab_offset](#at_tab_offset "Control tab width calculations"),
//// [with_tab_width](#with_tab_width "Control tab width calculations")
////
//// #### Advanced
//// [fold](#fold "Loop grapheme clusters and their position/size")<small>[[_with](#fold_with)]</small>,
//// [fold_raw](#fold_raw "Loop low-level segments and their un-processed width"),
//// [fold_raw_pieces](#fold_raw_pieces "Loop low-level segments and their position/size")
////
// can we talk about how weird it is that I only used 3 things from the stdlib??
// what's up with that?
import gleam/int
import gleam/list
import gleam/string
import string_width/internal/ansi
import string_width/internal/tables
// sources:
// UAX #11 east asian width: https://www.unicode.org/reports/tr11/
// Grapheme Clusters in Terminals: https://mitchellh.com/writing/grapheme-clusters-in-terminals
// get-east-asian-width: https://github.com/sindresorhus/get-east-asian-width
// string-width: https://github.com/sindresorhus/string-width
// ansi-regex: https://github.com/chalk/ansi-regex
// fast-string-truncated-width: https://github.com/fabiospampinato/fast-string-truncated-width/
// musl-libc wcwidth: https://git.musl-libc.org/cgit/musl/tree/src/ctype/wcwidth.c
// Mode 2027: https://github.com/contour-terminal/terminal-unicode-core
// https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/36
// https://docs.rs/unicode-width/latest/unicode_width/#rules-for-determining-width
// https://github.com/muesli/reflow/
// https://github.com/charmbracelet/x/
// v3.2.1:
// - try poslen -> split for binary states (align, limit)
// so far, switching away from string.append was never worth it.
// - cprof, eprof, eflamb`e
// - propose a @inline attribute
// - propose changing the js codegen to generate constructor calls instead of withFields
// v3.3.0:
// - what about grid??
// I don't have a design right now that I like.
// - `length` variant for measure that works more like the old line
// Why did I think this was useful?
// v4.0.0:
// - fix labeled arguments in position_with
// - think _again_ about changing fold to be called with an EOS marker at the end
// - remove max_width from align?
// on the other hand, it makes sense that you would usually want to align
// strings in a pre-determined box.
// - actually, replace align with align_lines
// - position with overflow-hidden? we have scroll now.
// - limit -> reflow/fit that doesn't respect newlines in the original string
// - split stuff into 2/3 packages?
// -- OPTIONS ------------------------------------------------------------------
/// Options to change the default behaviour of the functions in this library.
/// If you are unsure what to do here, the defaults should work great!
pub opaque type Options {
Options(
count_ansi_codes: Bool,
ambiguous_as_wide: Bool,
mode: Mode,
tab_width: Int,
tab_offset: Int,
)
}
type Mode {
ModeWcwidth
Mode2027
Mode2027Ext
}
const default_options = Options(
count_ansi_codes: False,
ambiguous_as_wide: False,
mode: ModeWcwidth,
tab_width: 8,
tab_offset: 0,
)
/// Start building up new options.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn new() -> Options {
default_options
}
/// Measure grapheme clusters instead of individual code points.
///
/// Most terminal emulators do not handle grapheme clusters well and will
/// instead show their decomposition. To make sure a given string always fits
/// even on those terminals, the functions in this package will copy this
/// behaviour as well.
///
/// If you enable this option, the returned numbers will more accurately represent
/// the width of a string on a website or in an editor.
///
/// **NOTE:** Grapheme support in terminals is highly experimental and subject
/// to ongoing discussion! When working on CLIs or TUIs, you do not want to use
/// this option most of the time.
///
/// See also [Grapheme Clusters and Terminal Emulators](https://mitchellh.com/writing/grapheme-clusters-in-terminals)
/// for a better explanation on how terminals behave.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn mode_2027(options: Options) -> Options {
Options(..options, mode: Mode2027)
}
/// Measure grapheme clusters instead of individual code points, and always
/// treat grapheme clusters with more than one non-modifier character as wide.
///
/// This is a custom extension to Unicode Core. Many single grapheme clusters
/// would currently still render as wide glyphs in many contexts, since font
/// shaping would not be able to collapse them into a single narrow "ligature".
///
/// This mode is an additional heuristic that tries to handle more such cases.
///
/// See `mode_2027` for more explanation on the implications.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn mode_2027_ext(options: Options) -> Options {
Options(..options, mode: Mode2027Ext)
}
/// The default mode and the mode suitable for most terminals.
///
/// Measures individual code points, similar to the libc `wcwidth` function
/// that almost all of these terminals use.
///
/// Note that except for `fold_raw`, this library will still always process
/// grapheme clusters as a unit.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn mode_wcwidth(options: Options) -> Options {
Options(..options, mode: ModeWcwidth)
}
/// Some characters are marked by Unicode as "ambiguous", meaning they may
/// occupy 1 or 2 cells, depending on the context, current language, selected
/// font, surrounding text, and more.
///
/// Unicode recommends treating these characters as narrow by default,
/// but you can change this behaviour using this option.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn ambiguous_as_wide(options: Options) -> Options {
Options(..options, ambiguous_as_wide: True)
}
/// Do not ignore ansi escape sequences, and count them as regular characters.
///
/// You can enable this option as an optimisation if you are sure that your
/// string doesn't contain any ansi escape codes.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn count_ansi_codes(options: Options) -> Options {
Options(..options, count_ansi_codes: True)
}
/// Change the number of columns between tab stops. (Default: 8)
///
/// Whenever a tab character `\t` is encountered, the current column number will
/// be rounded up to the next multiple of this number.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn with_tab_width(options: Options, tab_width: Int) -> Options {
Options(..options, tab_width:)
}
/// Define an _offset_ for tab stop calculations. (Default: 0)
///
/// If the text you print doesn't start at the first column, but is instead
/// indented somehow, you can set this option to this number to make sure tab
/// stops are correctly calculated.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn at_tab_offset(options: Options, tab_offset: Int) -> Options {
Options(..options, tab_offset:)
}
// -- MEASURING STRINGS --------------------------------------------------------
/// A custom type making it easy to differentiate the width (columns) and
/// height (rows).
pub type Size {
Size(rows: Int, columns: Int)
}
/// Get the size of the terminal, if available.
///
/// Checks the built-in methods for the target, as well as the `LINES` and
/// `COLUMNS` environment variables.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn get_terminal_size() -> Result(Size, Nil) {
case do_get_terminal_size() {
Ok(#(rows, columns)) -> Ok(Size(rows:, columns:))
_ -> Error(Nil)
}
}
@external(erlang, "string_width_ffi", "get_terminal_size")
@external(javascript, "./string_width_ffi.mjs", "get_terminal_size")
fn do_get_terminal_size() -> Result(#(Int, Int), Nil)
/// Get the number of columns required to print a line in a terminal.
///
/// If multiple lines are given, the length of the longest line is returned.
///
/// ### Examples
///
/// ```gleam
/// line("äöüè")
/// // --> 4
///
/// line("안녕하세요")
/// // --> 10
///
/// line("👩‍👩‍👦‍👦")
/// // --> 8
///
/// line("\u{1B}[31mhello\u{1B}[39m")
/// // --> 5
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn line(str: String) -> Int {
let Size(rows: _, columns:) = dimensions_with(str, default_options)
columns
}
/// Like `line`, but use custom options.
///
/// ### Example
///
/// ```gleam
/// let options =
/// new()
/// |> mode_2027
///
/// line_with("👩‍👩‍👦‍👦", options)
/// // --> 2
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn line_with(str: String, options: Options) -> Int {
let Size(rows: _, columns:) = dimensions_with(str, options)
columns
}
/// The required number of rows and columns to display the given text.
///
/// The number of rows is equal to the number of lines, while the number of
/// columns represents the maximum line width.
///
/// ### Examples
///
/// ```gleam
/// dimensions("안녕하세요")
/// // --> Size(rows: 1, columns: 10)
///
/// dimensions("hello,\n안녕하세요")
/// // --> Size(rows: 2, columns: 10)
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn dimensions(str: String) -> Size {
dimensions_with(str, default_options)
}
/// Like `dimensions`, but use custom options.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn dimensions_with(str: String, options: Options) -> Size {
let #(str, ranges, range_width) = prepare_measure(options, str)
let fun = fn(state, chr, width) {
let #(rows, cols_max, cols_curr) = state
case chr {
"\n" -> #(rows + 1, int.max(cols_max, cols_curr), options.tab_offset)
"\t" -> #(rows, cols_max, cols_curr + tab(options, cols_curr))
_ -> #(rows, cols_max, cols_curr + width)
}
}
let on_range = fn(state, ansi, length) {
fun(state, ansi, range_width * length)
}
let on_chars = case options.mode {
ModeWcwidth -> fn(state, str) { fold_chars_raw(options, str, state, fun) }
Mode2027 -> fn(state, str) { fold_chars_2027(options, str, state, fun) }
Mode2027Ext -> fn(state, str) {
fold_chars_2027_ext(options, str, state, fun)
}
}
let initial = #(0, 0, options.tab_offset)
let #(rows, cols_max, cols_curr) =
fold_parts(str, 0, ranges, initial, on_chars, on_range)
let cols_max = int.max(cols_max, cols_curr) - options.tab_offset
let rows = case cols_curr > options.tab_offset {
True -> rows + 1
False -> rows
}
Size(rows: rows, columns: cols_max)
}
/// Get the width of a tab at the given column.
fn tab(options: Options, col: Int) -> Int {
{ col / options.tab_width + 1 } * options.tab_width - col
}
// magic hook that allows us to not be stupid with FFI and still get
// _most_ of the performance (we are now like 50% slower, or back to old speed).
@external(javascript, "./string_width_ffi.mjs", "prepare_measure")
fn prepare_measure(
options: Options,
str: String,
) -> #(String, List(#(Int, Int)), Int) {
let ansi_ranges = case options.count_ansi_codes {
True -> []
False -> ansi.match(str)
}
#(str, ansi_ranges, 0)
}
// -- LAYOUT UTILS -------------------------------------------------------------
/// Limit the dimensions of a string, either by wrapping on white space
/// characters, or by truncating the last line and appending an ellipsis.
///
/// The lines of the resulting string are left aligned and are at most `columns`
/// wide. If a single word is longer than the maximum allowed line width, the
/// word is broken into 2 pieces at the grapheme level. If the string gets
/// truncated, this function will keep collecting ANSI sequences to make sure
/// all formatting is preserved.
///
/// A commonly used ellipsis character is `"…"`, also known as
/// U+2026 HORIZONTAL ELLIPSIS.
///
/// ### Examples
///
/// ```gleam
/// limit("Hello World", Size(rows: 1, columns: 10), ellipsis: "...")
/// // --> "Hello W..."
///
/// limit("Hello World", Size(rows: 2, columns: 10), ellipsis: "...")
/// // --> "Hello\nWorld"
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn limit(
str: String,
to max_size: Size,
ellipsis ellipsis: String,
) -> String {
limit_with(str, max_size, default_options, ellipsis)
}
type LimitState {
LimitState(
str: String,
row: Int,
col: Int,
spaces: String,
spaces_width: Int,
non_spaces: String,
non_spaces_width: Int,
overflow: String,
overflow_width: Int,
)
}
fn limit_state_col(state: LimitState) {
state.col + state.spaces_width + state.non_spaces_width + state.overflow_width
}
// find the grapheme cluster boundary to set the new overflow.
fn limit_state_overflow(
state: LimitState,
piece: String,
options: Options,
) -> #(String, String, Int) {
let str = state.non_spaces <> state.overflow <> piece
let #(before, after, width) = {
use #(before, after, width), piece <- fold_with(str, options, #("", "", 0))
case piece.column + piece.width > state.non_spaces_width {
True -> #(before, after <> piece.piece, width + piece.width)
False -> #(before <> piece.piece, after, width)
}
}
case before == "" {
True -> #(before, after, width)
False -> #(state.spaces <> before, after, width)
}
}
/// Like `limit`, but also customise the options used for measuring.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn limit_with(
str: String,
to max_size: Size,
using options: Options,
ellipsis ellipsis: String,
) -> String {
let ellipsis_width = spacer_width(options, ellipsis)
let max_row = max_size.rows
// we encode a few states, but we do it this way such that spread works...
// - if state.row == max_size.rows, we have reached the end and should do nothing
// - if we have oveflow, we are at the last line and stuff doesn't fit.
// - if we have non_spaces or overflow, we are collecting word characters.
// - if we do not, we are collecting spaces _before_ a word.
// - on the first space we collect, if we notice that we have word characters,
// we try to push this word into the accumulator.
// if it doesn't fit on the last line, we have overflow, and can append the ellipsis.
// afterwards, we set row = max_sie.rows to indicate we are finished.
let commit = fn(max_col: Int, state: LimitState, piece: String, width: Int) {
let new_col = limit_state_col(state)
// make sure we definitely have room for the ellipsis on the last line
let is_last_line = state.row + 1 >= max_row
case state.non_spaces == "" && state.overflow == "" {
// we do not have non-spaces, so we are still collecting spaces.
True ->
case state.str {
// skip spaces at the start, since we skip them at the end.
"" -> state
_ ->
LimitState(
spaces: state.spaces <> piece,
spaces_width: state.spaces_width + width,
str: state.str,
row: state.row,
col: state.col,
non_spaces: state.non_spaces,
non_spaces_width: state.non_spaces_width,
overflow: state.overflow,
overflow_width: state.overflow_width,
)
}
// we have non-spaces, so we want to push this word into the accumulator
// and then start again collecting spaces.
False ->
// would it fit into the line just normally?
case new_col <= max_col {
True -> {
// it fits normally, nothing special required!
let str =
state.str <> state.spaces <> state.non_spaces <> state.overflow
LimitState(
str:,
row: state.row,
col: new_col,
spaces: piece,
spaces_width: width,
non_spaces: "",
non_spaces_width: 0,
overflow: "",
overflow_width: 0,
)
}
False -> {
// it doesn't; if we are at the last line, truncate. else, wrap.
case is_last_line {
True -> {
let #(truncated, _, _) =
limit_state_overflow(state, "", options)
LimitState(
str: state.str <> truncated <> ellipsis,
row: state.row + 1,
col: max_size.columns,
spaces: "",
spaces_width: 0,
non_spaces: "",
non_spaces_width: 0,
overflow: "",
overflow_width: 0,
)
}
False ->
LimitState(
str: state.str <> "\n" <> state.non_spaces <> state.overflow,
row: state.row + 1,
col: state.non_spaces_width + state.overflow_width,
spaces: piece,
spaces_width: width,
non_spaces: "",
non_spaces_width: 0,
overflow: "",
overflow_width: 0,
)
}
}
}
}
}
let initial =
LimitState(
str: "",
row: 0,
col: 0,
spaces: "",
spaces_width: 0,
non_spaces: "",
non_spaces_width: 0,
overflow: "",
overflow_width: 0,
)
let state = {
use state, piece, width <- fold_raw(str, options, initial)
// io.debug(#(state, piece, width))
let is_last_line = state.row + 1 >= max_row
let max_col = case is_last_line {
True -> max_size.columns - ellipsis_width
False -> max_size.columns
}
case state.row >= max_row {
// reached the end, but we still want to collect ansi sequences
True ->
case is_ansi_component(piece, options) {
True ->
LimitState(
str: state.str <> piece,
row: state.row,
col: state.col,
spaces: state.spaces,
spaces_width: state.spaces_width,
non_spaces: state.non_spaces,
non_spaces_width: state.non_spaces_width,
overflow: state.overflow,
overflow_width: state.overflow_width,
)
False -> state
}
False ->
case piece {
// Line break that we recognise as such
"\n" -> {
let state = commit(max_col, state, "", 0)
LimitState(
str: state.str <> "\n",
row: state.row + 1,
col: 0,
spaces: state.spaces,
spaces_width: state.spaces_width,
non_spaces: state.non_spaces,
non_spaces_width: state.non_spaces_width,
overflow: state.overflow,
overflow_width: state.overflow_width,
)
}
"\t" -> {
// tab has a different width depending on whether or not we will wrap
let curr_col = limit_state_col(state)
let width = case curr_col <= max_col {
True -> tab(options, options.tab_offset + curr_col)
False -> {
let wrapped_col = state.non_spaces_width + state.overflow_width
tab(options, options.tab_offset + wrapped_col)
}
}
commit(max_col, state, piece, width)
}
// White_Space=Y
" "
| "\u{a0}"
| "\u{1680}"
| "\u{2000}"
| "\u{2001}"
| "\u{2002}"
| "\u{2003}"
| "\u{2004}"
| "\u{2005}"
| "\u{2006}"
| "\u{2007}"
| "\u{2008}"
| "\u{2009}"
| "\u{200a}"
| "\u{202f}"
| "\u{205f}"
| "\u{3000}" -> commit(max_col, state, piece, width)
// not a space or a line break
_ -> {
case limit_state_col(state) + width <= max_col {
True ->
// everything's fine and normal :)
LimitState(
non_spaces: state.non_spaces <> piece,
non_spaces_width: state.non_spaces_width + width,
str: state.str,
row: state.row,
col: state.col,
spaces: state.spaces,
spaces_width: state.spaces_width,
overflow: state.overflow,
overflow_width: state.overflow_width,
)
False -> {
// these non_space characters no longer fit the line,
// but what if overflow is too long as well?
case
state.non_spaces_width + state.overflow_width + width
> max_size.columns
{
True -> {
let #(truncated, overflow, overflow_width) =
limit_state_overflow(state, piece, options)
// it _is_! do we have room for another row?
case is_last_line {
False ->
LimitState(
str: state.str <> truncated <> "\n",
row: state.row + 1,
non_spaces: overflow,
non_spaces_width: overflow_width,
col: 0,
spaces: "",
spaces_width: 0,
overflow: "",
overflow_width: 0,
)
True ->
// no, word to long this is the last row, sorry.
LimitState(
str: state.str <> truncated <> ellipsis,
row: state.row + 1,
col: max_size.columns,
spaces: "",
spaces_width: 0,
non_spaces: "",
non_spaces_width: 0,
overflow: "",
overflow_width: 0,
)
}
}
False ->
LimitState(
overflow: state.overflow <> piece,
overflow_width: state.overflow_width + width,
str: state.str,
row: state.row,
col: state.col,
spaces: state.spaces,
spaces_width: state.spaces_width,
non_spaces: state.non_spaces,
non_spaces_width: state.non_spaces_width,
)
}
}
}
}
}
}
}
// commit the last word, if any
let state = commit(max_size.columns, state, "", 0)
state.str
}
/// Replace all tab characters found in the string with the amount of spaces
/// that this tab would have had otherwise.
///
/// This makes it safe to prepend to a line, without changing the spacing produced
/// by tabs anymore.
///
/// ### Examples
///
/// ```gleam
/// tabs_to_spaces("Hello\tWorld")
/// // --> "Hello World" // 3 spaces
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn tabs_to_spaces(str: String) -> String {
tabs_to_spaces_with(str, default_options)
}
/// Like `tabs_to_spaces`, but customise the opttions as well.
///
/// **NB:** You can use `at_tab_offset` and `with_tab_width` to change the measure of
/// tab characters inside the string!
///
/// ```gleam
/// let options = new() |> with_tab_width(4)
/// tabs_to_spaces_with("hi\tcutie~", options)
/// // --> "hi cutie~"
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn tabs_to_spaces_with(str: String, using options: Options) -> String {
use acc, piece <- fold_raw_pieces(str, options, from: "")
case piece.piece {
"\t" -> acc <> string.repeat(" ", piece.width)
_ -> acc <> piece.piece
}
}
/// Control the text or box alignment on the horizontal axis.
pub type Alignment {
Left
Center
Right
}
/// Control the box alignment on the vertical axis.
pub type Placement {
Top
Middle
Bottom
}
/// Position the string area inside a bigger box without changing text alignment.
/// The box will be filled with the space character.
///
/// **Tip:** If you only want to align a string on one axis, you can provide
/// `0` for the orthogonal one! Extra characters and lines will be kept.
///
/// If the space strings' width does not evenly divide the missing amount of
/// columns, the extra spacer will overflow the max width.
///
/// ### Examples
///
/// ```gleam
/// position("X", in: Size(3, 3), align: Center, place: Middle, with: "o")
/// // --> "ooo\noXo\nooo"
///
/// // text still left-aligned, but moved to the right by 1 space
/// position("Trans\nrights\nare\nhuman\nrights", Size(0, 7), Right, Top, " ")
/// // --> " Trans \n rights\n are \n human \n rights"
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn position(
str: String,
in bounding_box: Size,
align alignment: Alignment,
place placement: Placement,
with space: String,
) -> String {
position_with(str, bounding_box, alignment, placement, default_options, space)
}
/// Like `position`, but use custom options to measure each line.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn position_with(
str: String,
in bounding_box: Size,
horizontal alignment: Alignment,
vertical placement: Placement,
using options: Options,
with space: String,
) -> String {
let size = dimensions_with(str, options)
let left = case alignment {
Left -> 0
Right -> int.min(size.columns - bounding_box.columns, 0)
Center -> int.min({ size.columns - bounding_box.columns } / 2, 0)
}
let top = case placement {
Top -> 0
Bottom -> int.min(size.rows - bounding_box.rows, 0)
Middle -> int.min({ size.rows - bounding_box.rows } / 2, 0)
}
let width = int.max(size.columns, bounding_box.columns)
let height = int.max(size.rows, bounding_box.rows)
box(str, size, top:, left:, width:, height:, using: options, with: space)
}
/// Align each line horizontally, using a spacer character or string as a filler.
/// Each line of the return value will be at least `max_width` wide.
///
/// If a line is already longer than the max width, it will not be changed.
/// If the space strings width does not evenly divide the missing amount of
/// columns, the extra spacer will overflow the max width.
///
/// ### Examples
///
/// ```gleam
/// align("hello", to: 10, align: Left, with: " ")
/// // --> "hello "
///
/// align("12345", to: 8, align: Right, with: "0")
/// // --> "00012345"
///
/// align(" Welcome ", to: 20, align: Center, with: "==")
/// // --> "====== Welcome ======" // (len = 21)
///
/// align("Trans\nrights\nare\nhuman\nrights", to: 7, align: Right, with: " ")
/// // --> " Trans\n rights\n are\n human\n rights"
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn align(
str: String,
to max_width: Int,
align alignment: Alignment,
with space: String,
) -> String {
align_with(str, max_width, alignment, default_options, space)
}
/// Like `align`, bu use custom options for measure.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn align_with(
str: String,
to max_width: Int,
align alignment: Alignment,
using options: Options,
with space: String,
) -> String {
let space_width = spacer_width(options, space)
let str_size = dimensions_with(str, options)
let bottom = str_size.rows
let right = int.max(str_size.columns, max_width)
use line, line_width <- view(str, str_size, 0, 0, options, bottom:, right:)
let missing = div_up(max_width - line_width, space_width)
case alignment {
Left -> line <> string.repeat(space, missing)
Right -> string.repeat(space, missing) <> line
Center -> {
let left = missing / 2
let right = missing - left
string.repeat(space, left) <> line <> string.repeat(space, right)
}
}
}
/// Add some padding around the box of a string, filling empty space and the
/// padded area with the provided character.
///
/// Padding is given as a number of rows/columns. Values are given clockwise,
/// following the CSS shorthand property.
///
/// Ansi sequences will always be kept. If the space strings' width does not
/// evenly divide the missing area in the computed outer size of the string,
/// the extra spacer will the resulting lines may not all be equally long.
///
/// **Tip:** Provide negative values to drop columns or rows!
///
/// ### Examples
///
/// ```gleam
/// padding("o", 1, 1, 1, 1, with: "x")
/// // --> "xxx\nxox\nxxx"
///
/// padding("hello", 0, 0, 0, right: -1, with: "")
/// // --> "hell"
///
/// padding("Trans\nrights!", top: 2, right: 5, bottom: 1, left: 1, with: "-")
/// // --> "-------------\n-------------\n-Trans-------\n-rights!-----\n-------------"
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn padding(
str: String,
top top: Int,
right right: Int,
bottom bottom: Int,
left left: Int,
with space: String,
) -> String {
let options = default_options
let size = dimensions_with(str, options)
// padding is like negative scrolling -
// if we want more spaces, we need to move the viewport in the opposite direction.
let width = size.columns + left + right
let height = size.rows + top + bottom
box(str, size, options, top: -top, left: -left, width:, height:, with: space)
}
/// Like `padding`, but use custom options for measuring.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn padding_with(
str: String,
top top: Int,
right right: Int,
bottom bottom: Int,
left left: Int,
using options: Options,
with space: String,
) -> String {
let size = dimensions_with(str, options)
// padding is like negative scrolling -
// if we want more spaces, we need to move the viewport in the opposite direction.
let width = size.columns + left + right
let height = size.rows + top + bottom
box(str, size, options, top: -top, left: -left, width:, height:, with: space)
}
/// Cut out a `viewort` at the given origin `#(top, left)` from a string. This
/// is similar to _"scrolling"_ a web page.
///
/// You can scroll past the filled area of the string in any direction.
/// Empty space is filled using the `space` string. If the space string does not
/// evenly fill the empty area, some lines may be longer than the given viewport.
///
/// All ANSI sequences in the entire string will be kept, even if they are
/// outside of the visible viewport.
///
/// ### Example
///
/// ```gleam
/// scroll("wibble", top: 0, left: 2, view: Size(1, 2), with: " ")
/// // --> "bb"
///
/// "Hello\nHow are you today?\nDid you know:\nLucy says trans rights!"
/// |> scroll(top: 2, left: 10, view: Size(3, 5), with: "-")
/// // --> "ow:--\ntrans\n-----"
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn scroll(
str: String,
top top: Int,
left left: Int,
view viewport_size: Size,
with space: String,
) -> String {
let options = default_options
let size = dimensions_with(str, options)
let Size(rows: height, columns: width) = viewport_size
box(str, size, options, top:, left:, width:, height:, with: space)
}
/// Like `scroll`, but provide custom options for measuring.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn scroll_with(
str: String,
top top: Int,
left left: Int,
view viewport_size: Size,
using options: Options,
with space: String,
) -> String {
let size = dimensions_with(str, options)
let Size(rows: height, columns: width) = viewport_size
box(str, size, top:, left:, width:, height:, using: options, with: space)
}
/// Stack multiple blocks of strings on top of each other into multiple rows.
///
/// The resulting string will as wide as the widest string in the list. All other
/// blocks can be `position`-ed horizontally relative to this size.
///
/// ### Example
///
/// ```gleam
/// [
/// "Hello!",
/// "I hope you are feeling fantastic today!"
/// ]
/// |> stack_vertical(Center, gap: 1, with: " ")
/// // --> " Hello! \n"
/// // <> " \n"
/// // <> "I hope you are feeling fantastic today!"
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn stack_vertical(
blocks: List(String),
align horizontal: Alignment,
gap gap: Int,
with space: String,
) -> String {
stack_vertical_with(blocks, horizontal, gap, default_options, space)
}
/// Like `stack_vertical`, but using custom options for measure.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn stack_vertical_with(
blocks: List(String),
align horizontal: Alignment,
gap gap: Int,
using options: Options,
with space: String,
) -> String {
use first, blocks <- guard_list(blocks, "")
let first_size = dimensions_with(first, options)
let measured = {
use block <- list.map(blocks)
#(block, dimensions_with(block, options))
}
let width = {
use max, #(_, Size(_, width)) <- list.fold(measured, first_size.columns)
int.max(max, width)
}
let space_width = spacer_width(options, space)
let gap_line = string.repeat(space, div_up(width, space_width))
let gap_str = string.repeat("\n" <> gap_line, gap)
let render = fn(block: String, size: Size) {
let left = case horizontal {
Left -> 0
Right -> size.columns - width
Center -> { size.columns - width } / 2
}
box(block, size, 0, left, size.rows, width, options, space)
}
use buf, #(block, size) <- list.fold(measured, render(first, first_size))
buf <> gap_str <> "\n" <> render(block, size)
}
/// Stack multiple blocks of strings horizontally next to each other into
/// multiple columns.
///
/// The resulting string will as high as the highest string in the list. All
/// other blocks can be `position`-ed vertically relative to this size.
///
/// **Tip;** Use [limit](#limit) and [inline_styles](#inline_styles) to lay out
/// the text in your columns!
///
/// ### Example
///
/// ```gleam
/// [
/// "--color",
/// "Enable color support.\nThis option is enabled by default in a terminal."
/// ]
/// |> stack_horizontal(place: Top, gap: 4, with: " ")
/// // --> "--color Enable color support. \n"
/// // <> " This option is enabled by default in a terminal."
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn stack_horizontal(
blocks: List(String),
place vertical: Placement,
gap gap: Int,
with space: String,
) -> String {
stack_horizontal_with(blocks, vertical, gap, default_options, space)
}
/// Like `stack_horizontal`, but using custom options for measure.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn stack_horizontal_with(
blocks: List(String),
place vertical: Placement,
gap gap: Int,
using options: Options,
with space: String,
) -> String {
use first, blocks <- guard_list(blocks, "")
let first_size = dimensions_with(first, options)
let measured = {
use block <- list.map(blocks)
#(block, dimensions_with(block, options))
}
// min height is 1, in case you pass all empty strings.
let height =
int.max(1, {
use max, #(_, Size(height, _)) <- list.fold(measured, first_size.rows)
int.max(max, height)
})
let space_width = spacer_width(options, space)
let gap_str = string.repeat(space, div_up(gap, space_width))
let render = fn(lines: List(String), block: String, size: Size, push_column) {
// we use view_fold here to build all lines "in parallel"
let push = fn(state, line, line_width) {
case state {
#([line_so_far, ..rest_input], output) -> {
let missing_right = div_up(size.columns - line_width, space_width)
let padding_right = string.repeat(space, missing_right)
let line = push_column(line_so_far, line <> padding_right)
#(rest_input, [line, ..output])
}
_ -> state
}
}
let top = case vertical {
Top -> 0
Bottom -> size.rows - height
Middle -> { size.rows - height } / 2
}
let bottom = top + height
let right = size.columns
let #(_, formatted_lines) =
view_fold(block, size, top, 0, bottom, right, options, #(lines, []), push)
list.reverse(formatted_lines)
}
// render first block
let lines = {
use _, line <- render(list.repeat("", height), first, first_size)
line
}
// render rest of the blocks
let lines = {
use lines, #(block, size) <- list.fold(measured, lines)
use line_so_far, line <- render(lines, block, size)
line_so_far <> gap_str <> line
}
string.join(lines, with: "\n")
}
/// cutout a viewport of a string in a rectangular manner, filling the remaining
/// space with the spacer. Internal version of scroll or padding.
fn box(
str: String,
str_size: Size,
top top: Int,
left left: Int,
height height: Int,
width width: Int,
using options: Options,
with space: String,
) {
let space_width = spacer_width(options, space)
let missing_left = div_up(int.max(0, -left), space_width)
let padding_left = string.repeat(space, missing_left)
let bottom = top + height
let right = left + width
use line, line_width <- view(str, str_size, top, left, bottom, right, options)
let missing_right = div_up(width - missing_left - line_width, space_width)
let padding_right = string.repeat(space, missing_right)
padding_left <> line <> padding_right
}
type ViewState(state) {
ViewState(acc: state, line: String, row: Int, col: Int)
}
/// cutout a viewport of a string.
/// the passed align function can pad the string to the right length, etc.
/// `align` uses a different align function to box.
fn view(
str: String,
size: Size,
top top: Int,
left left: Int,
bottom bottom: Int,
right right: Int,
using options: Options,
align align: fn(String, Int) -> String,
) {
let push = fn(buf, line, line_width) {
buf <> "\n" <> align(line, line_width)
}
let result = view_fold(str, size, top, left, bottom, right, options, "", push)
case result {
"" -> result
_ -> {
// TODO: this is terrible.
let #(_, result) = unsafe_split(result, 1)
result
}
}
}
/// collect a viewport/cutout of a string into a custom data structure.
/// push is supposed to pad/align/etc however it sees fit.
/// `stack_horizontal` uses this to collect all lines in parallel.
fn view_fold(
str: String,
str_size: Size,
top top: Int,
left left: Int,
bottom bottom: Int,
right right: Int,
using options: Options,
from initial: state,
push push: fn(state, String, Int) -> state,
) -> state {
let Size(rows: str_rows, columns: _) = str_size
let line_width_offset = int.max(0, left)
let push_line = fn(state: ViewState(state)) -> ViewState(state) {
case state.row < top || state.row >= bottom {
True -> {
ViewState(acc: state.acc, line: state.line, row: state.row + 1, col: 0)
}
False -> {
let acc = push(state.acc, state.line, state.col - line_width_offset)
ViewState(acc:, line: "", row: state.row + 1, col: 0)
}
}
}
// vertical padding top
let acc = repeat(-top, initial, push(_, "", 0))
let state = {
use state, piece, width <- fold_raw(str, options, ViewState(acc, "", 0, 0))
let width = case piece {
"\t" -> tab(options, state.col + options.tab_offset)
_ -> width
}
case piece {
"\n" -> push_line(state)
_ ->
case
state.row < top
|| state.row >= bottom
|| state.col < left
|| state.col >= right
{
True ->
case is_ansi_component(piece, options) {
True ->
ViewState(
acc: state.acc,
line: state.line <> piece,
row: state.row,
col: state.col,
)
False ->
ViewState(
acc: state.acc,
line: state.line,
row: state.row,
col: state.col + width,
)
}
False ->
ViewState(
acc: state.acc,
line: state.line <> piece,
row: state.row,
col: state.col + width,
)
}
}
}
// do not push a line with padding if the last line is empty
let acc = case state.line == "" && state.col == 0 {
True -> state.acc
False -> push(state.acc, state.line, state.col - line_width_offset)
}
// vertical padding bottom
let acc = repeat(bottom - str_rows, acc, push(_, "", 0))
acc
}
/// Integer division, rounding up
fn div_up(numerator: Int, denom: Int) {
{ numerator + denom - 1 } / denom
}
/// do something, multiple times.
fn repeat(times: Int, from state: state, do fun: fn(state) -> state) {
case times > 0 {
True -> repeat(times - 1, fun(state), fun)
False -> state
}
}
/// line_with, but with a few fast paths for commmonly used strings.
fn spacer_width(options: Options, str: String) -> Int {
case str {
"" -> 0
" " -> 1
"..." -> 3
"…" -> 1
"0" -> 1
_ -> line_with(str, options)
}
}
/// early return on empty lists.
fn guard_list(list: List(a), empty: b, non_empty: fn(a, List(a)) -> b) -> b {
case list {
[] -> empty
[head, ..tail] -> non_empty(head, tail)
}
}
// -- ANSI HELPERS -------------------------------------------------------------
/// Make sure [SGR ansi codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters)
/// (these are the ones you'd use for colors and styling!) never wrap
/// around a line. This makes it save to use other layout functions on the
/// resulting string, without affecting the styling anymore.
///
/// It does so by collecting all SGR sequences since the last reset, and
/// re-applying them on each line break.
///
/// **Warning:** This function assumes it is safe to emit reset commands,
/// and that no ambient styling is in effect (that is, the terminal would be in
/// its default state when printing the resulting string!)
///
/// ### Examples
///
/// ```gleam
/// // "\u{1b}[31m": red text color
/// // "\u{1b}[m": reset/normal mode
/// inline_styles("\u{1b}[31mhello\nworld")
/// // --> "\u{1b}[31mhello" <> "\u{1b}[m\n" <> "\u{1b}[31mworld" <> "\u{1b}[m"
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn inline_styles(str: String) -> String {
let ansi_ranges = ansi.match(str)
let on_range = fn(state, ansi, _) {
let #(buf, sgr) = state
case ansi {
"\u{1b}[0m" | "\u{1b}[m" -> #(buf <> ansi, "")
_ ->
case string.ends_with(ansi, "m") {
True -> #(buf <> ansi, sgr <> ansi)
False -> #(buf <> ansi, sgr)
}
}
}
let on_chars = fn(state, chars) {
case state {
#(buf, "") -> #(buf <> chars, "")
#(buf, sgr) -> {
let #(rest, _, buf) = {
use #(ptr, pos, buf), byte <- fold_bytes(chars, #(chars, 0, buf))
case byte {
0xA -> {
let assert #(line, "\n" <> ptr) = unsafe_split(ptr, pos)
#(ptr, 0, buf <> line <> "\u{1b}[m\n" <> sgr)
}
_ -> #(ptr, pos + 1, buf)
}
}
#(buf <> rest, sgr)
}
}
}
let state = fold_parts(str, 0, ansi_ranges, #("", ""), on_chars, on_range)
case state {
#(buf, "") -> buf
#(buf, _sgr) -> buf <> "\u{1b}[m"
}
}
/// Strip _all_ ansi sequences from a string, using the same matching algorithm
/// that this library uses internally.
///
/// This makes it safe to enable `count_ansi_codes`, or print a string without
/// having to worry that it might mess with the terminal and/or colors.
///
/// Unexpected characters inside of escape sequences are ignored (swallowed).
///
/// ### Examples
///
/// ```gleam
/// strip_ansi("\u{1b}[0;33;49;3;9;4mhi~\u{1b}[0m")
/// // --> "hi~"
///
/// strip_ansi("check out \u{1b}]8;;https://gleam.run\u{7}Gleam!\u{1b}]8;;\u{7}")
/// // --> "check out Gleam!"
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn strip_ansi(str: String) -> String {
ansi.strip(str)
}
/// Returns true if a given component string recieved in `fold` is an ANSI
/// escape sequence.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn is_ansi_component(str: String, options: Options) -> Bool {
case options.count_ansi_codes {
True -> False
False ->
case str {
"\u{1b}" <> _ | "\u{9b}" <> _ | "\u{9d}" <> _ -> True
_ -> False
}
}
}
// -- FOLD ---------------------------------------------------------------------
/// The measured pieces of a string, passed to you by `fold` and `fold_with`
pub type Piece {
Piece(piece: String, row: Int, column: Int, width: Int)
}
/// A higher-level `fold` that keeps track of the position inside of the string.
///
/// Handles tabs and newlines, and always passes full grapheme clusters,
/// regardless of options. Concatenating the graphemes produces the original string.
///
/// Intended to be used as a basis for custom layout algorithms.
///
/// ### Example
///
/// ```gleam
/// fold("hi 😊", from: [], with: list.prepend)
/// |> list.reverse
/// // --> [Piece("h", 0, 0, 1), Piece("i", 0, 1, 1), Piece(" ", 0, 2, 1), Piece("😊", 0, 3, 2)]
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn fold(
over str: String,
from state: state,
with fun: fn(state, Piece) -> state,
) -> state {
fold_with(str, default_options, state, fun)
}
/// A higher-level `fold` that keeps track of the position inside of the string.
/// Like `fold`, but using custom options to measure the pieces.
///
/// Handles tabs and newlines, and always passes full grapheme clusters,
/// regardless of options. Concatenating the graphemes produces the original string.
///
/// Intended to be used as a basis for custom layout algorithms.
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn fold_with(
over str: String,
using options: Options,
from state: state,
with fun: fn(state, Piece) -> state,
) -> state {
let #(state, fun) = piece_reducer(state, options, fun)
let on_chars = case options.mode {
ModeWcwidth -> fn(state, str) {
fold_chars_wcwidth(options, str, state, fun)
}
Mode2027 -> fn(state, str) { fold_chars_2027(options, str, state, fun) }
Mode2027Ext -> fn(state, str) {
fold_chars_2027_ext(options, str, state, fun)
}
}
let ansi_ranges = case options.count_ansi_codes {
True -> []
False -> ansi.match(str)
}
let on_range = fn(state, ansi, _) { fun(state, ansi, 0) }
let #(state, _, _) =
fold_parts(str, 0, ansi_ranges, state, on_chars, on_range)
state
}
/// A low-level fold that iterate over the measured components of a string.
/// Components are either graphemes or codepoints depending on the `mode` option,
/// or other undivisible sequences, like ANSI escape codes.
///
/// This function does not by itself keep track of any additional state, and
/// doesn't for example handle newlines or tabs like `fold` would.
/// If you know that your algorithm is safe and won't break up graphemes (or
/// maybe this is acceptable), you can use this fold variant instead as a
/// performance optimisation.
///
/// Concatenating all components is guaranteed to produce the original string.
///
/// ### Examples
///
/// ```gleam
/// // A slower string_width.line implementation that doesn't handle tabs
/// fold_raw("hello", new(), from: 0, with: fn(so_far, _chr, width) { width + so_far })
/// // --> 5
/// ```
///
/// ```gleam
/// // truncate a string after 50 characters, but keep all ansi sequences.
/// let options = new()
/// use #(total, acc), chr, width <- fold_raw(input, options, from: #(0, ""))
/// case total >= 50 {
/// True -> case is_ansi_component(chr, options) {
/// True -> #(total, acc <> chr)
/// False -> #(total, acc)
/// }
/// False -> #(total + width, acc <> chr)
/// }
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn fold_raw(
over string: String,
using options: Options,
from state: state,
with fun: fn(state, String, Int) -> state,
) -> state {
let ansi_ranges = case options.count_ansi_codes {
True -> []
False -> ansi.match(string)
}
let on_chars = case options.mode {
ModeWcwidth -> fn(state, str) { fold_chars_raw(options, str, state, fun) }
Mode2027 -> fn(state, str) { fold_chars_2027(options, str, state, fun) }
Mode2027Ext -> fn(state, str) {
fold_chars_2027_ext(options, str, state, fun)
}
}
let on_range = fn(state, ansi, _) { fun(state, ansi, 0) }
fold_parts(string, 0, ansi_ranges, state, on_chars, on_range)
}
/// A version of `fold_raw` that does keep track of some state for you to
/// handle tabs, and count the current row/column, passing `Piece` values to
/// you instead.
///
/// ### Example
///
/// ```gleam
/// fold_raw_pieces("hi!", using: new(), from: "", with: list.prepend)
/// |> list.reverse
/// // --> [Piece("h", 0, 0, 1), Piece("i", 0, 1, 1), Piece("!", 0, 2, 1)]
/// ```
///
/// <div style="text-align: right;">
/// <a href="#">
/// <small>Back to top ↑</small>
/// </a>
/// </div>
pub fn fold_raw_pieces(
over string: String,
using options: Options,
from state: state,
with fun: fn(state, Piece) -> state,
) -> state {
let ansi_ranges = case options.count_ansi_codes {
True -> []
False -> ansi.match(string)
}
let #(state, fun) = piece_reducer(state, options, fun)
let on_chars = case options.mode {
ModeWcwidth -> fn(state, str) { fold_chars_raw(options, str, state, fun) }
Mode2027 -> fn(state, str) { fold_chars_2027(options, str, state, fun) }
Mode2027Ext -> fn(state, str) {
fold_chars_2027_ext(options, str, state, fun)
}
}
let on_range = fn(state, ansi, _) { fun(state, ansi, 0) }
let #(state, _, _) =
fold_parts(string, 0, ansi_ranges, state, on_chars, on_range)
state
}
fn piece_reducer(
state: state,
options: Options,
fun: fn(state, Piece) -> state,
) -> #(
#(state, Int, Int),
fn(#(state, Int, Int), String, Int) -> #(state, Int, Int),
) {
let initial = #(state, 0, options.tab_offset)
let fun = fn(state, piece, width) {
let #(state, row, column) = state
let width = case piece {
"\t" -> tab(options, column)
_ -> width
}
let state = fun(state, Piece(piece:, row:, column:, width:))
case piece {
"\n" -> #(state, row + 1, options.tab_offset)
_ -> #(state, row, column + width)
}
}
#(initial, fun)
}
fn fold_parts(
over string: String,
at offset: Int,
ranges ranges: List(#(Int, Int)),
from state: state,
on_chars on_chars: fn(state, String) -> state,
on_range on_range: fn(state, String, Int) -> state,
) -> state {
case ranges {
[#(start, length), ..ranges] if start == offset -> {
let #(range_slice, string) = unsafe_split(string, length)
let state = on_range(state, range_slice, length)
let offset = start + length
fold_parts(string, offset, ranges, state, on_chars, on_range)
}
[#(start, length), ..ranges] -> {
let #(before, at_range) = unsafe_split(string, start - offset)
let #(range_slice, string) = unsafe_split(at_range, length)
let state =
state
|> on_chars(before)
|> on_range(range_slice, length)
let offset = start + length
fold_parts(string, offset, ranges, state, on_chars, on_range)
}
[] ->
case string {
"" -> state
_ -> on_chars(state, string)
}
}
}
fn fold_chars_raw(
options: Options,
string: String,
state: state,
fun: fn(state, String, Int) -> state,
) -> state {
use state, cp <- fold_codepoints(string, state)
fun(state, utf_codepoint_to_string(cp), wcwidth(options, cp))
}
fn fold_chars_wcwidth(
options: Options,
string: String,
state: state,
fun: fn(state, String, Int) -> state,
) -> state {
use state, grapheme <- fold_graphemes(string, state)
let width = {
use acc, cp <- fold_codepoints(grapheme, 0)
acc + wcwidth(options, cp)
}
fun(state, grapheme, width)
}
fn fold_chars_2027(
options: Options,
string: String,
state: state,
fun: fn(state, String, Int) -> state,
) -> state {
use state, grapheme <- fold_graphemes(string, state)
let width = measure_mode_2027(options, grapheme)
fun(state, grapheme, width)
}
fn fold_chars_2027_ext(
options: Options,
string: String,
state: state,
fun: fn(state, String, Int) -> state,
) -> state {
use state, grapheme <- fold_graphemes(string, state)
let width = measure_mode_2027_ext(options, grapheme)
fun(state, grapheme, width)
}
fn measure_mode_2027(options: Options, string: String) -> Int {
use max, cp <- fold_codepoints(string, 0)
case cp {
// unicode core: VS16 forces a cluster to be wide
0xfe0f if max > 0 -> 2
_ -> int.max(max, wcwidth(options, cp))
}
}
fn measure_mode_2027_ext(options: Options, string: String) -> Int {
// The Unicode Core spec (https://github.com/contour-terminal/terminal-unicode-core)
// proposing Mode 2027 says the following:
//
// > Therefore, extending a grapheme cluster with consecutively added codepoints
// > will not move the cursor except for variation selector 16 (VS16) that may
// > have caused the width of the grapheme cluster to change to wide (2 grid cells).
//
// I do not agree.
//
// Many grapheme clusters include multiple letters with a width, which cannot
// be printed and combined into a single cell. (examples: च्छे षि ‍র্য バ)
//
// Therefore, in addition to the rules in this proposal, having 2 or more
// non-modifier codepoints with width in a cluster forces it to wide.
//
// This more accurately represents what font shaping would produce in
// non-terminal applications, and not a single terminal in my tests implements
// the aformentioned spec correctly (in my understanding) anyways.
use max, cp <- fold_codepoints(string, 0)
case max == 0 {
True -> wcwidth(options, cp)
False ->
case cp {
// unicode core: VS16 forces a cluster to be wide
0xfe0f -> 2
_ -> {
case wcwidth(options, cp) {
0 -> max
// extension: 2 or more codepoints with width force a cluster to be wide
width ->
case is_spacing_mark(cp) {
True -> int.max(max, width)
False -> 2
}
}
}
}
}
}
fn wcwidth(options: Options, cp: Int) -> Int {
// see: https://git.musl-libc.org/cgit/musl/tree/src/ctype/wcwidth.c
case cp {
// cheap optimization for ascii
_ if cp <= 0x1f -> 0
_ if cp <= 0x7e -> 1
_ ->
case is_ignorable(cp) {
True -> 0
False ->
case
is_wide(cp) || { options.ambiguous_as_wide && is_ambiguous(cp) }
{
True -> 2
False -> 1
}
}
}
}
// -- TABLE ACCESSORS ----------------------------------------------------------
// the data for these functions is generated by the generate.gleam script.
// the general idea is heavily inspired by the tables used in musl libc.
fn is_ignorable(cp: Int) -> Bool {
case cp <= 0x1ffff {
True -> table_lookup(tables.ignorable, cp)
False -> cp >= 0xe0000 && cp <= 0xe0fff
}
}
fn is_ambiguous(cp: Int) -> Bool {
case cp <= 0xffff {
True -> table_lookup(tables.ambiguous, cp)
False -> {
{ cp >= 0x1f100 && cp <= 0x1f10a }
|| { cp >= 0x1f110 && cp <= 0x1f12d }
|| { cp >= 0x1f130 && cp <= 0x1f169 }
|| { cp >= 0x1f170 && cp <= 0x1f18d }
|| { cp >= 0x1f18f && cp <= 0x1f190 }
|| { cp >= 0x1f19b && cp <= 0x1f1ac }
|| { cp >= 0xe0100 && cp <= 0xe01ef }
|| { cp >= 0xf0000 && cp <= 0xffffd }
|| { cp >= 0x100000 && cp <= 0x10FFFD }
}
}
}
fn is_wide(cp: Int) -> Bool {
case cp <= 0x1ffff {
True -> table_lookup(tables.wide, cp)
False -> cp >= 0x20000 && cp <= 0x40000
}
}
fn is_spacing_mark(cp: Int) -> Bool {
case cp <= 0x1ffff {
True -> table_lookup(tables.spacing_mark, cp)
False -> False
}
}
// -- FFI ----------------------------------------------------------------------
// size is not supported
@external(javascript, "./string_width_ffi.mjs", "table_lookup")
fn table_lookup(table: BitArray, value: Int) -> Bool {
let hi = int.bitwise_shift_right(value, 8)
let md = int.bitwise_shift_right(int.bitwise_and(value, 0xff), 3)
let lo = int.bitwise_and(value, 7)
let lvl1_skip = hi * 8
let assert <<_:size(lvl1_skip), lvl1:int, _:bits>> = table
let lvl2_skip = { lvl1 * 32 + md } * 8
let assert <<_:size(lvl2_skip), lvl3:int, _:bits>> = table
int.bitwise_shift_right(lvl3, lo) % 2 != 0
}
@external(javascript, "./string_width_ffi.mjs", "fold_graphemes")
fn fold_graphemes(
str: String,
state: state,
fun: fn(state, String) -> state,
) -> state {
case string.pop_grapheme(str) {
Ok(#(grapheme, str)) -> fold_graphemes(str, fun(state, grapheme), fun)
Error(Nil) -> state
}
}
@external(erlang, "string_width_ffi", "fold_codepoints")
@external(javascript, "./string_width_ffi.mjs", "fold_codepoints")
fn fold_codepoints(
str: String,
state: state,
fun: fn(state, Int) -> state,
) -> state {
use state, cp <- list.fold(string.to_utf_codepoints(str), state)
fun(state, string.utf_codepoint_to_int(cp))
}
@external(erlang, "string_width_ffi", "fold_bytes")
@external(javascript, "./string_width_ffi.mjs", "fold_bytes")
fn fold_bytes(str: String, state: state, fun: fn(state, Int) -> state) -> state
@external(erlang, "erlang", "split_binary")
@external(javascript, "./string_width_ffi.mjs", "unsafe_split")
fn unsafe_split(str: String, at: Int) -> #(String, String)
@external(erlang, "string_width_ffi", "utf_codepoint_to_string")
@external(javascript, "./string_width_ffi.mjs", "utf_codepoint_to_string")
fn utf_codepoint_to_string(cp: Int) -> String