//// //// //// 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")[[_with](#line_with)] //// [dimensions](#dimensions "Size of a string")[[_with](#dimensions_with)], //// [get_terminal_size](#get_terminal_size "Size of the terminal window") //// //// #### Layout & Positioning //// //// [limit](#limit "Word wrapping and truncation")[[_with](#limit_with)], //// [align](#align "Text alignment")[[_with](#align_with)], //// [tabs_to_spaces](#tabs_to_spaces "Pin tab widths")[[_with](#tabs_to_spaces_with)] //// //// [stack_horizontal](#stack_horizontal "Column-based layouts")[[_with](#stack_horizontal_with)], //// [stack_vertical](#stack_vertical "Row-based layouts")[[_with](#stack_vertical_with)], //// [padding](#padding "Add extra space around a string")[[_with](#padding_with)], //// [position](#position "Put a string inside a fixed-size box")[[_with](#position_with)], //// [scroll](#scroll "Cutout a viewport area from a string")[[_with](#scroll_with)] //// //// #### 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")[[_with](#fold_with)], //// [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. /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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 /// ``` /// ///
/// /// Back to top ↑ /// ///
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 /// ``` /// ///
/// /// Back to top ↑ /// ///
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) /// ``` /// ///
/// /// Back to top ↑ /// ///
pub fn dimensions(str: String) -> Size { dimensions_with(str, default_options) } /// Like `dimensions`, but use custom options. /// ///
/// /// Back to top ↑ /// ///
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" /// ``` /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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, excluding control characters and non-breaking spaces " " | "\u{1680}" | "\u{2000}" | "\u{2001}" | "\u{2002}" | "\u{2003}" | "\u{2004}" | "\u{2005}" | "\u{2006}" | "\u{2008}" | "\u{2009}" | "\u{200a}" | "\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 /// ``` /// ///
/// /// Back to top ↑ /// ///
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~" /// ``` /// ///
/// /// Back to top ↑ /// ///
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" /// ``` /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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, 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" /// ``` /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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, 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-------------" /// ``` /// ///
/// /// Back to top ↑ /// ///
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, options, top: -top, left: -left, width:, height:, with: space) } /// Like `padding`, but use custom options for measuring. /// ///
/// /// Back to top ↑ /// ///
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, 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-----" /// ``` /// ///
/// /// Back to top ↑ /// ///
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(rows: height, columns: width) = viewport_size box(str, options, top:, left:, width:, height:, with: space) } /// Like `scroll`, but provide custom options for measuring. /// ///
/// /// Back to top ↑ /// ///
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(rows: height, columns: width) = viewport_size box(str, 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!" /// ``` /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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, 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." /// ``` /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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) { // NOTE: we can't get negative widths here, since we won't scroll offscreen 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, 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, 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, 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 } // import gleam/io 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, 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) { case line_width >= 0 { True -> buf <> "\n" <> align(line, line_width) False -> buf <> line } } let result = view_fold(str, top, left, bottom, right, options, "", push) case result { "\n" <> result -> result _ -> 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, 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 total_height = int.max(0, bottom - top) 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(int.min(-top, total_height), 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, ) } } } // if we are inside the viewable rows, push a normal line // otherwise, push the remaining ansi sequences let acc = case state.row >= top && state.row < bottom { True -> push(state.acc, state.line, state.col - line_width_offset) False -> push(state.acc, state.line, -1) } // vertical padding bottom // if we have visible columns, we have already added a line with the previous push. let missing_bottom = case state.col >= line_width_offset { True -> bottom - state.row - 1 False -> bottom - state.row } repeat(int.min(missing_bottom, total_height), acc, push(_, "", 0)) } /// 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" /// ``` /// ///
/// /// Back to top ↑ /// ///
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!" /// ``` /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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)] /// ``` /// ///
/// /// Back to top ↑ /// ///
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. /// ///
/// /// Back to top ↑ /// ///
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) /// } /// ``` /// ///
/// /// Back to top ↑ /// ///
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)] /// ``` /// ///
/// /// Back to top ↑ /// ///
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