diff --git a/src/string_width.gleam b/src/string_width.gleam
index a855c06..0db57c9 100644
--- a/src/string_width.gleam
+++ b/src/string_width.gleam
@@ -70,7 +70,8 @@ import string_width/internal/tables
// https://github.com/muesli/reflow/
// https://github.com/charmbracelet/x/
-// v3.3.1:
+// v3.3.2:
+// - test ansi sequences during truncation
// - try poslen -> split for binary states (align, limit)
// so far, switching away from string.append was never worth it.
// - cprof, eprof, eflamb`e
@@ -563,6 +564,8 @@ pub fn soft_wrap(
soft_wrap_with(str, max_size, default_options, ellipsis, to_indent)
}
+// import gleam/io
+
/// Like `soft_wrap`, but using custom options to measure the strings and indents.
///
///
@@ -594,16 +597,18 @@ pub fn soft_wrap_with(
)
}
- let commit = fn(state, piece, width) {
+ let commit = fn(state, piece, width, is_final) {
// io.debug(#("commit", state, piece, width))
limit_state_commit(
options,
max_size,
ellipsis,
+ ellipsis_width,
to_indent,
state,
piece,
width,
+ is_final,
)
}
@@ -652,7 +657,8 @@ pub fn soft_wrap_with(
case piece {
// Line break that we recognise as such
"\n" -> {
- let state = commit(state, "", 0)
+ let state = commit(state, "", 0, False)
+ // io.debug(#("newline", state))
LimitState(
str: state.str <> state.line <> "\n",
line: "",
@@ -680,7 +686,7 @@ pub fn soft_wrap_with(
}
}
- commit(state, piece, width)
+ commit(state, piece, width, False)
}
// White_Space=Y, excluding control characters and non-breaking spaces
@@ -697,10 +703,10 @@ pub fn soft_wrap_with(
| "\u{2009}"
| "\u{200a}"
| "\u{205f}"
- | "\u{3000}" -> commit(state, piece, width)
+ | "\u{3000}" -> commit(state, piece, width, False)
// soft hyphen. we handle the details in commit
- "\u{ad}" -> commit(state, piece, width)
+ "\u{ad}" -> commit(state, piece, width, False)
// not a space or a line break
_ -> push(state, piece, width)
@@ -709,7 +715,8 @@ pub fn soft_wrap_with(
}
// commit the last word, if any
- let state = commit(state, "", 0)
+ let state = commit(state, "", 0, True)
+ // io.debug(#("final", state))
state.str <> state.line
}
@@ -718,19 +725,50 @@ type Indent {
Indent(str: String, width: Int)
}
+/// The loop state during text wrapping.
+///
+/// The general idea is to collect pieces of the source string into a "staging"
+/// area (spaces, non_spaces, overflow) until we know for sure what to do w.r.t.
+/// line breeaks, at which point we "commit" the staging strings into line and
+/// str respectivily.
+///
+/// commits are usually taken after we encounter a space character (as our
+/// approximation of a word boundary, since we do not implement unicode word
+/// segmentation), or after a shy or newline, in emergency situations where we
+/// have to break a word apart.
type LimitState {
LimitState(
+ /// the final committed buffer
str: String,
+ /// line is committed, but kept around separately if we need to compute indent.
line: String,
+ /// the current cursor position of the committed line (and str)
row: Int,
col: Int,
+ /// spaces in between non_spaces. collected separetely because we drop them
+ /// if we soft_wrap, and replace them by indent instead.
spaces: String,
spaces_width: Int,
+ /// non_spaces are characters that safely fit the current line together with
+ /// spaces without exceeding max_size.columns.
non_spaces: String,
non_spaces_width: Int,
+ /// non-space characters that are too long for the current line.
+ /// we collect them at first normally before we know if we want an emergency
+ /// line break or a normal soft-wrapping line break.
+ ///
+ /// we need to emergency break once non_spaces+overflow > max_columns-indent
+ /// at this point, we also need to reflow (truncate) the word to find the
+ /// emergency break boundary.
+ ///
+ /// if we are at the last line, we start overflowing ellipsis_width columns
+ /// earlier, to leave space for an ellipsis, if we have to push one.
overflow: String,
overflow_width: Int,
+ /// contains indent information if we already had to soft-wrap
indent: Indent,
+ /// true if the previous commit was due to a shy, and we should insert a
+ /// hyphen if we soft-wrap at this point.
shy: Bool,
)
}
@@ -739,25 +777,31 @@ 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)
- }
+fn limit_emergency_break_reflow(
+ str: String,
+ at max_col: Int,
+ using options: Options,
+) {
+ let initial = #("", "", 0)
+ use #(before, after, width), piece <- fold_with(str, options, initial)
+ case piece.column + piece.width > max_col {
+ True -> #(before, after <> piece.piece, width + piece.width)
+ False -> #(before <> piece.piece, after, width)
}
+}
- case before {
- "" -> #(before, after, width)
- _ -> #(state.spaces <> before, after, width)
+fn limit_emergency_truncate(
+ str: String,
+ at max_col: Int,
+ using options: Options,
+) {
+ use #(truncated, width), piece <- fold_with(str, options, #("", 0))
+ case
+ is_ansi_component(piece.piece, options)
+ || piece.column + piece.width <= max_col
+ {
+ True -> #(truncated <> piece.piece, width + piece.width)
+ False -> #(truncated, width)
}
}
@@ -787,14 +831,42 @@ fn limit_state_push(
piece: String,
width: Int,
) {
- let is_last_line = state.row + 1 >= max_size.rows
- let max_col = case is_last_line {
- True -> max_size.columns - ellipsis_width
- False -> max_size.columns
+ // we need to check for 2 boundaries:
+ // 1. the non_spaces vs overlfow boundary - does this character fit normally?
+ // 2. the emergency break boundary - do we have to commit?
+ //
+ // emergency breaking gets more complicated because we also need to check if we
+ // can actually line break (or need to truncate), and because we need to
+ // potentially reflow the overflow because of a indent we don't know about yet.
+
+ // the column we would end up with if we committed the word onto the current
+ // line normally, without soft-wrapping or emergency breaking.
+ let line_col = limit_state_col(state) + width
+
+ // the column to check against to see if we need to emergency break.
+ // keep in mind that this might bigger (by indent) if we don't know the indent yet.
+ let emergency_break_col = case state.indent {
+ IndentUnknown -> state.non_spaces_width + state.overflow_width + width
+ Indent(_, indent_width) ->
+ indent_width + state.non_spaces_width + state.overflow_width + width
}
- case limit_state_col(state) + width <= max_col {
- True ->
- // everything's fine and normal :)
+
+ // the column at which we would start pushing into overflow instead of non_spaces.
+ // if emergency_break_col overflows this, we need to emergency break instead.
+ let lines_remaining = max_size.rows - state.row - 1
+ let overflow_col = case lines_remaining {
+ 0 -> max_size.columns - ellipsis_width
+ _ -> max_size.columns
+ }
+
+ // we can use the fact that line_col >= emergency_break_col
+ // we cannot take emergency line breaks anymore on the last line, so we just collect into overflow.
+ case
+ line_col <= overflow_col,
+ emergency_break_col <= overflow_col || lines_remaining == 0
+ {
+ True, _ -> {
+ // this character fits normally and we can collect it into non_spaces right now.
LimitState(
non_spaces: state.non_spaces <> piece,
non_spaces_width: state.non_spaces_width + width,
@@ -810,105 +882,144 @@ fn limit_state_push(
indent: state.indent,
shy: state.shy,
)
- // these non_space characters no longer fit the line,
- // but what if overflow is too long as well?
- False ->
- case
- state.non_spaces_width + state.overflow_width + width
- >= max_size.columns
- {
- True -> {
- // it _is_! do we have room for another row?
- case is_last_line {
- True -> {
- let #(truncated, _, _) =
- limit_state_overflow(state, piece, options)
- // no, word to long this is the last row, sorry.
- LimitState(
- str: state.str <> state.line <> truncated <> ellipsis,
- line: "",
- row: state.row + 1,
- col: max_size.columns,
- spaces: "",
- spaces_width: 0,
- non_spaces: "",
- non_spaces_width: 0,
- overflow: "",
- overflow_width: 0,
- indent: IndentUnknown,
- shy: False,
- )
- }
+ }
+ False, True -> {
+ // this word does not fit the current line.
+ // at this point we know that we will either soft-wrap or emergency break,
+ // so we can pre-compute the indent.
+ // This is important such that our emergency_break_col is correct.
+ let indent = get_indent(state.line <> state.spaces <> state.non_spaces)
+ let indent_width = line_with(indent, options)
- False -> {
- let #(indent, indent_width) =
- limit_state_indent(get_indent, options, state)
- // in case the last break was a shy, we want to always take it.
- case state.shy {
- True -> {
- // the characters we collected so far form another full line
- LimitState(
- str: state.str
- <> state.line
- <> "-\n"
- <> indent
- <> state.non_spaces
- <> state.overflow
- <> piece
- <> "\n",
- line: indent,
- row: state.row + 2,
- non_spaces: "",
- non_spaces_width: 0,
- col: indent_width,
- indent: Indent(indent, indent_width),
- shy: False,
- spaces: "",
- spaces_width: 0,
- overflow: "",
- overflow_width: 0,
- )
- }
- False -> {
- let #(truncated, overflow, overflow_width) =
- limit_state_overflow(state, piece, options)
- LimitState(
- str: state.str <> state.line <> truncated <> "\n",
- line: indent,
- row: state.row + 1,
- non_spaces: overflow,
- non_spaces_width: overflow_width,
- col: indent_width,
- indent: Indent(indent, indent_width),
- shy: False,
- spaces: "",
- spaces_width: 0,
- overflow: "",
- overflow_width: 0,
- )
- }
- }
- }
- }
+ case state.non_spaces_width == 0 && lines_remaining > 0 {
+ True -> {
+ // this is the first non-space character that we push, but we would
+ // already overflow! this is annoying, so let's fix it.
+ LimitState(
+ str: state.str <> state.line <> state.non_spaces <> "\n",
+ line: indent,
+ col: indent_width,
+ row: state.row + 1,
+ non_spaces: state.overflow <> piece,
+ non_spaces_width: state.overflow_width + width,
+ indent: Indent(indent, indent_width),
+ overflow: "",
+ overflow_width: 0,
+ spaces: "",
+ spaces_width: 0,
+ shy: False,
+ )
}
- // overflow has not yet overlfown itself, we're "fine" for now.
False ->
LimitState(
overflow: state.overflow <> piece,
overflow_width: state.overflow_width + width,
+ indent: Indent(indent, indent_width),
+ //
str: state.str,
line: state.line,
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,
- indent: state.indent,
+ spaces: state.spaces,
+ spaces_width: state.spaces_width,
shy: state.shy,
)
}
+ }
+ False, False -> {
+ // in an emergency break, we have at least 2 lines we want to push
+ // - if the last commit was at a shy, we always want to take it.
+ // - we at least have 1 full line that we need to reflow to figure out the breakpoint
+ // - push the remaining characters into the new staging area.
+
+ let #(indent, indent_width) =
+ limit_state_indent(get_indent, options, state)
+ // we can only push a shy if we are not yet at the last line.
+ case lines_remaining, state.shy {
+ 1, True -> {
+ // we want to take this shy break, but we have only 1 line left after that
+ let #(truncated, _) =
+ limit_emergency_truncate(
+ state.non_spaces <> state.overflow <> piece,
+ max_size.columns - ellipsis_width - indent_width,
+ options,
+ )
+
+ LimitState(
+ str: state.str
+ <> state.line
+ <> "-\n"
+ <> indent
+ <> truncated
+ <> ellipsis,
+ col: max_size.columns,
+ row: max_size.rows,
+ indent: Indent(indent, indent_width),
+ //
+ line: "",
+ shy: False,
+ spaces: "",
+ spaces_width: 0,
+ non_spaces: "",
+ non_spaces_width: 0,
+ overflow: "",
+ overflow_width: 0,
+ )
+ }
+
+ _, True -> {
+ // we want to take this shy break, and have plenty of space.
+ let #(before, after, after_width) =
+ limit_emergency_break_reflow(
+ state.non_spaces <> state.overflow <> piece,
+ max_size.columns - indent_width,
+ options,
+ )
+
+ LimitState(
+ str: state.str <> state.line <> "-\n" <> indent <> before <> "\n",
+ line: indent,
+ non_spaces: after,
+ non_spaces_width: after_width,
+ col: indent_width,
+ row: state.row + 2,
+ indent: Indent(indent, indent_width),
+ shy: False,
+ spaces: "",
+ spaces_width: 0,
+ overflow: "",
+ overflow_width: 0,
+ )
+ }
+
+ _, False -> {
+ let #(truncated, overflow, overflow_width) =
+ limit_emergency_break_reflow(
+ state.non_spaces <> state.overflow <> piece,
+ state.non_spaces_width,
+ options,
+ )
+
+ LimitState(
+ str: state.str <> state.line <> state.spaces <> truncated <> "\n",
+ line: indent,
+ non_spaces: overflow,
+ non_spaces_width: overflow_width,
+ row: state.row + 1,
+ col: indent_width,
+ indent: Indent(indent, indent_width),
+ spaces: "",
+ spaces_width: 0,
+ overflow: "",
+ overflow_width: 0,
+ shy: False,
+ )
+ }
+ }
+ }
}
}
@@ -917,10 +1028,12 @@ fn limit_state_commit(
options: Options,
max_size: Size,
ellipsis: String,
+ ellipsis_width: Int,
get_indent: fn(String) -> String,
state: LimitState,
piece: String,
width: Int,
+ is_final: Bool,
) {
// 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
@@ -945,16 +1058,17 @@ fn limit_state_commit(
False -> piece
}
- case state.non_spaces == "" && state.overflow == "" {
+ case state.non_spaces_width == 0, state.overflow_width == 0 {
// we do not have non-spaces, so we are still collecting spaces.
- True ->
- case state.str {
+ True, True ->
+ case state.row == 0 && state.col == 0 {
// skip spaces at the start, since we skip them at the end.
- "" -> state
- _ ->
+ True -> state
+ False ->
LimitState(
spaces: state.spaces <> piece,
spaces_width: state.spaces_width + width,
+ //
str: state.str,
line: state.line,
row: state.row,
@@ -973,20 +1087,47 @@ fn limit_state_commit(
//
// we already know that non-spaces will fit, otherwise we would have pushed
// into overflow.
- False ->
- // would it fit into the line just normally?
- // it fits normally if there is no overflowt and we break not because of a
- // shy, or the hyphen would also fit.
- case state.overflow == "" {
- True -> {
- // it fits normally, nothing special required!
- let line =
- state.line <> state.spaces <> state.non_spaces <> state.overflow
+ // would it fit into the line just normally?
+ // it fits normally if there is no overflow and we break not because of a
+ // shy, or the hyphen would also fit.
+ False, True -> {
+ // it fits normally, nothing special required!
+ let line =
+ state.line <> state.spaces <> state.non_spaces <> state.overflow
+ LimitState(
+ str: state.str,
+ line: line,
+ row: state.row,
+ col: new_col,
+ spaces: piece,
+ spaces_width: width,
+ shy: shy && new_col + 1 < max_size.columns,
+ indent: state.indent,
+ 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, is_final, state.overflow_width > ellipsis_width {
+ True, True, False -> {
+ // final push where the overflow is not longer than the ellipsis
LimitState(
- str: state.str,
- line: line,
+ str: state.str
+ <> state.line
+ <> state.spaces
+ <> state.non_spaces
+ <> state.overflow,
+ line: "",
+ col: state.col
+ + state.spaces_width
+ + state.non_spaces_width
+ + state.overflow_width,
row: state.row,
- col: new_col,
spaces: piece,
spaces_width: width,
non_spaces: "",
@@ -994,61 +1135,67 @@ fn limit_state_commit(
overflow: "",
overflow_width: 0,
indent: state.indent,
- shy: new_col + 1 <= max_size.columns && shy,
+ shy: False,
)
}
- False -> {
- // it doesn't; if we are at the last line, truncate. else, wrap.
- // but first, push the shy of the previous break, if we have one
- let line = case state.shy {
- True -> state.line <> "-"
- False -> state.line
+ True, _, _ -> {
+ let #(truncated, truncated_width) =
+ limit_emergency_truncate(
+ state.non_spaces <> state.overflow <> piece,
+ state.non_spaces_width,
+ options,
+ )
+
+ // TODO: check for truncated_width instead.
+ let str = case truncated_width {
+ 0 -> state.str <> state.line <> truncated <> ellipsis
+ _ ->
+ state.str <> state.line <> state.spaces <> truncated <> ellipsis
}
- case is_last_line {
- True -> {
- let #(truncated, _, _) = limit_state_overflow(state, "", options)
- LimitState(
- str: state.str <> line <> truncated <> ellipsis,
- line: "",
- row: state.row + 1,
- col: max_size.columns,
- spaces: "",
- spaces_width: 0,
- non_spaces: "",
- non_spaces_width: 0,
- overflow: "",
- overflow_width: 0,
- indent: IndentUnknown,
- shy: False,
- )
- }
+ LimitState(
+ str:,
+ line: "",
+ row: state.row + 1,
+ col: max_size.columns,
+ spaces: "",
+ spaces_width: 0,
+ non_spaces: "",
+ non_spaces_width: 0,
+ overflow: "",
+ overflow_width: 0,
+ indent: IndentUnknown,
+ shy: False,
+ )
+ }
- False -> {
- let #(indent, indent_width) =
- limit_state_indent(get_indent, options, state)
-
- LimitState(
- str: state.str <> line <> "\n",
- line: indent <> state.non_spaces <> state.overflow,
- row: state.row + 1,
- col: indent_width
- + state.non_spaces_width
- + state.overflow_width,
- spaces: piece,
- spaces_width: width,
- non_spaces: "",
- non_spaces_width: 0,
- overflow: "",
- overflow_width: 0,
- indent: Indent(indent, indent_width),
- shy: shy,
- )
- }
+ False, _, _ -> {
+ let #(indent, indent_width) =
+ limit_state_indent(get_indent, options, state)
+
+ let str = case state.shy {
+ True -> state.str <> state.line <> "-\n"
+ False -> state.str <> state.line <> "\n"
}
+
+ LimitState(
+ str: str,
+ line: indent <> state.non_spaces <> state.overflow,
+ row: state.row + 1,
+ col: indent_width + state.non_spaces_width + state.overflow_width,
+ spaces: piece,
+ spaces_width: width,
+ non_spaces: "",
+ non_spaces_width: 0,
+ overflow: "",
+ overflow_width: 0,
+ indent: Indent(indent, indent_width),
+ shy: shy,
+ )
}
}
+ }
}
}
@@ -1602,8 +1749,6 @@ fn box(
padding_left <> line <> padding_right
}
-// import gleam/io
-
type ViewState(state) {
ViewState(acc: state, line: String, row: Int, col: Int)
}
diff --git a/test/human_rights.gleam b/test/human_rights.gleam
index 571f8af..2e67b15 100644
--- a/test/human_rights.gleam
+++ b/test/human_rights.gleam
@@ -1,5 +1,4 @@
import gleam/int
-import gleam/string
import string_width.{Center, Middle, Size}
const human_rights = "
diff --git a/test/layout_test.gleam b/test/layout_test.gleam
index 56beccb..237dd14 100644
--- a/test/layout_test.gleam
+++ b/test/layout_test.gleam
@@ -15,9 +15,15 @@ pub fn limit_test() {
limit("Hello World", Size(2, 5), "...")
|> should.equal("Hello\nWorld")
+ limit("Hello World!", Size(3, 5), "...")
+ |> should.equal("Hello\nWorld\n!")
+
limit("How are you doing?", Size(1, 8), "…")
|> should.equal("How are…")
+ limit("Hello brave new world", Size(4, 5), "")
+ |> should.equal("Hello\nbrave\nnew\nworld")
+
limit(
"Lorem ipsum dolor sit amet\nIs a common placeholder string",
to: Size(3, 20),
@@ -89,6 +95,9 @@ pub fn soft_wrap_test() {
soft_wrap("Hello World", Size(2, 10), "...", fn(_) { " " })
|> should.equal("Hello\n World")
+ soft_wrap("Hello World", Size(3, 5), "...", fn(_) { " " })
+ |> should.equal("Hello\n Wor\n ld")
+
soft_wrap(
"Lorem ipsum dolor sit amet\nIs a common placeholder string",
to: Size(3, 20),
@@ -252,6 +261,9 @@ pub fn scroll_offscreen_test() {
}
pub fn align_test() {
+ align("", to: 10, align: Left, with: " ")
+ |> should.equal(" ")
+
align("hello", to: 10, align: Left, with: " ")
|> should.equal("hello ")