diff --git a/probe/pop-improved.js b/probe/pop-improved.js index a38cc2b..cf771b7 100644 --- a/probe/pop-improved.js +++ b/probe/pop-improved.js @@ -1,4 +1,4 @@ -const str = "😀".repeat(100) +const str = "😀".repeat(10) const segmenter = new Intl.Segmenter(); function fold_graphemes(str, state, fun) { diff --git a/probe/pop-rust.mjs b/probe/pop-rust.mjs index 2c15a0d..1a8a7d6 100644 --- a/probe/pop-rust.mjs +++ b/probe/pop-rust.mjs @@ -1,6 +1,6 @@ import { graphemeSegments } from 'unicode-segmenter/grapheme'; -const str = "😀".repeat(100) +const str = "😀".repeat(10) function fold_graphemes(str, state, fun) { for(const { segment: grapheme } of graphemeSegments(str)) { diff --git a/src/string_width.gleam b/src/string_width.gleam index d12b1be..b875cce 100644 --- a/src/string_width.gleam +++ b/src/string_width.gleam @@ -252,11 +252,342 @@ fn prepare_measure( // -- TRUNCATE / PAD ----------------------------------------------------------- -// "make lines be width" -// line to short? align left/right/center/stretch -// line to long? truncate/wrap -// break at? shys/whitespace/unicode word boudnaries/grahpeme boundaries? this is hard -// justify vs text align +/// 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: 5), ellipsis: "...") +/// // --> "Hello\nWorld" +/// ``` +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 +} + +/// Like `limit`, but also customise the options used for measuring. +pub fn limit_with( + str: String, + to max_size: Size, + using options: Options, + ellipsis ellipsis: String, +) -> String { + let ellipsis_width = line_with(ellipsis, options) + + let initial = + LimitState( + str: "", + row: 0, + col: 0, + spaces: "", + spaces_width: 0, + non_spaces: "", + non_spaces_width: 0, + overflow: "", + overflow_width: 0, + ) + + // 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 push_space = fn(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 max_col = case state.row + 1 >= max_size.rows { + True -> max_size.columns - ellipsis_width + False -> max_size.columns + } + + 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( + ..state, + spaces: state.spaces <> piece, + spaces_width: state.spaces_width + 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 + // does our new space fit though? + case state.row + 1 >= max_size.rows && new_col + width > max_col { + False -> + LimitState( + str:, + row: state.row, + col: new_col, + spaces: piece, + spaces_width: width, + non_spaces: "", + non_spaces_width: 0, + overflow: "", + overflow_width: 0, + ) + + True -> + LimitState( + str:, + row: state.row, + col: new_col, + spaces: "", + spaces_width: 0, + non_spaces: "", + non_spaces_width: 0, + overflow: piece, + overflow_width: width, + ) + } + } + + False -> + // it doesn't; if we are at the last line, truncate. else, wrap. + case state.row + 1 >= max_size.rows { + True -> + // we are at the last line. + LimitState( + ..state, + str: state.str <> state.spaces <> state.non_spaces <> ellipsis, + row: state.row + 1, + ) + + 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 state = { + // TODO: this may be fold_raw-able + use state, piece <- fold_with(str, options, initial) + case state.row >= max_size.rows { + // reached the end, but we still want to collect ansi sequences + True -> + case is_ansi_component(piece.piece, options) { + True -> LimitState(..state, str: state.str <> piece.piece) + False -> state + } + + False -> + case piece.piece { + // Line break that we recognise as such + "\n" | "\r\n" -> { + let state = push_space(state, "", 0) + LimitState( + ..state, + str: state.str <> "\n", + row: state.row + 1, + col: 0, + ) + } + + "\t" + | // White_Space=Y, Non line-break + " " + | "\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}" -> push_space(state, piece.piece, piece.width) + + // not a space or a line break + _ -> { + let max_col = case state.row + 1 >= max_size.rows { + True -> max_size.columns - ellipsis_width + False -> max_size.columns + } + + case limit_state_col(state) + piece.width > max_col { + True -> { + // 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 + piece.width + > max_size.columns + { + True -> + // it _is_! do we have room for another row? + case state.row + 1 < max_size.rows { + True -> + LimitState( + str: state.str + <> state.spaces + <> state.non_spaces + <> "\n", + col: 0, + row: state.row + 1, + non_spaces: state.overflow <> piece.piece, + non_spaces_width: state.overflow_width + piece.width, + spaces: "", + spaces_width: 0, + overflow: "", + overflow_width: 0, + ) + + False -> + // no, word to long this is the last row, sorry. + LimitState( + ..state, + str: state.str + <> state.spaces + <> state.non_spaces + <> ellipsis, + row: state.row + 1, + ) + } + + False -> + LimitState( + ..state, + overflow: state.overflow <> piece.piece, + overflow_width: state.overflow_width + piece.width, + ) + } + } + + False -> + // everything's fine and normal :) + LimitState( + ..state, + non_spaces: state.non_spaces <> piece.piece, + non_spaces_width: state.non_spaces_width + piece.width, + ) + } + } + } + } + } + + case + state.row >= max_size.rows + || { state.non_spaces == "" && state.overflow == "" } + { + // either reached the end, or we have no word characters left. + True -> state.str + False -> { + let new_col = limit_state_col(state) + // would it fit into the line just normally? + case new_col <= max_size.columns { + True -> state.str <> state.spaces <> state.non_spaces <> state.overflow + + False -> + // it doesn't; if we are at the last line, truncate. else, wrap. + case state.row + 1 >= max_size.rows { + True -> state.str <> state.spaces <> state.non_spaces <> ellipsis + False -> state.str <> "\n" <> state.non_spaces <> state.overflow + } + } + } + } +} + +/// Replace all tab characters found in the string with the amount of spaces +/// that this tab would have had otherwise. +/// +/// This makes it save to prepend to a line, without changing the spacing produced +/// by tabs anymore. +/// +/// ### Examples +/// +/// ```gleam +/// tabs_to_spaces("Hello\tWorld") +/// // --> "Hello World" // 3 spaces +/// ``` +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 `tab_offset` and `tab_width` to change the measure of +/// tab characters inside the string! +/// +/// ```gleam +/// let options = new() |> tab_width(4) +/// tabs_to_spaces_with("hi\tcutie~", options) +/// // --> "hi cutie~" +/// ``` +pub fn tabs_to_spaces_with(str: String, using options: Options) -> String { + use acc, piece <- fold_with(str, options, from: "") + case piece.piece { + "\t" -> acc <> string.repeat(" ", piece.width) + _ -> acc <> piece.piece + } +} // fold over words? (handle shy hyphens/soft breaks etc) // I do not want to implement full unicode word segmentation here though