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 ")