From fe94b7d6b57d29e45707651ae00f4e564f1ce0f9 Mon Sep 17 00:00:00 2001 From: Joshua Reusch Date: Thu, 17 Oct 2024 16:44:18 +0000 Subject: [PATCH] padding! position now leaves tabs alone --- src/string_width.gleam | 345 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------ test/columns.gleam | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------------------------- test/layout_test.gleam | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 3 file(s) changed, 352 insertion(s)(+), 167 deletion(s)(-) diff --git a/src/string_width.gleam b/src/string_width.gleam --- a/src/string_width.gleam +++ b/src/string_width.gleam @@ -7,8 +7,9 @@ //// //// #### Layout //// [limit](#limit)[[_with](#limit_with)], -//// [position](#position)[[_with](#position_with)], //// [align](#align)[[_with](#align_with)], +//// [padding](#padding)[[_with](#padding_with)], +//// [position](#position)[[_with](#position_with)], //// [tabs_to_spaces](#tabs_to_spaces)[[_with](#tabs_to_spaces_with)], //// [inline_styles](#inline_styles) //// @@ -47,20 +48,28 @@ // v3.2.0: // x collect/reset SGR codes like reflow does // - make align trim lines? -// - padding layout -// - join/columns layout -// - drop_left/drop_right +// x padding layout, switch position to use padding internally +// x remove do_measure +// - make sure position -> padding only measures the string once +// - rewrite align (think about trimming) +// - join/columns layout (grid? table?) +// x drop_left/drop_right - padding with negative margins? +// - cut/viewort/scroll(!!) function as an alternative to position with overflow? +// provide the area you want to view and we will compute the padding for that. // x change the module header to not list _with functions separately // x add "Back to top" links to all functions -// - explore skipping multiple bytes at a time like OTP json (8 bytes check if interesting -> continue) -// - propose a @inline attribute +// x explore skipping multiple bytes at a time like OTP json (8 bytes check if interesting -> continue) // - try poslen -> split for binary states (align, limit) // - cprof, eprof, eflamb`e +// - propose a @inline attribute +// - propose changing the js codegen to generate constructor calls instead of withFields // v4.0.0: // - fix labeled arguments in position_with - -const max_safe_integer = 0x1fffffffffffff +// - think _again_ about changing fold to be called with an EOS marker at the end +// - remove max_width from align +// - position with overflow-hidden (is this not just scroll?) +// - split stuff into 2/3 packages? // -- OPTIONS ------------------------------------------------------------------ @@ -278,7 +287,7 @@ /// /// pub fn line(str: String) -> Int { - let #(_, columns, _) = do_measure(str, default_options) + let Size(rows: _, columns:) = dimensions_with(str, default_options) columns } @@ -301,7 +310,7 @@ /// /// pub fn line_with(str: String, options: Options) -> Int { - let #(_, columns, _) = do_measure(str, options) + let Size(rows: _, columns:) = dimensions_with(str, options) columns } @@ -326,8 +335,7 @@ /// /// pub fn dimensions(str: String) -> Size { - let #(rows, columns, _) = do_measure(str, default_options) - Size(rows:, columns:) + dimensions_with(str, default_options) } /// Like `dimensions`, but use custom options. @@ -338,25 +346,15 @@ /// /// pub fn dimensions_with(str: String, options: Options) -> Size { - let #(rows, columns, _) = do_measure(str, options) - Size(rows:, columns:) -} - -fn do_measure(str: String, options: Options) -> #(Int, Int, Int) { let #(str, ranges, range_width) = prepare_measure(options, str) let fun = fn(state, chr, width) { - let #(rows, cols_max, cols_min, cols_curr) = state + let #(rows, cols_max, cols_curr) = state case chr { - "\n" -> #( - rows + 1, - int.max(cols_max, cols_curr), - int.min(cols_max, cols_curr), - options.tab_offset, - ) - "\t" -> #(rows, cols_max, cols_min, cols_curr + tab(options, cols_curr)) - _ -> #(rows, cols_max, cols_min, cols_curr + width) + "\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) } } @@ -372,8 +370,8 @@ } } - let initial = #(0, 0, max_safe_integer, options.tab_offset) - let #(rows, cols_max, cols_min, cols_curr) = + 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 @@ -381,12 +379,8 @@ True -> rows + 1 False -> rows } - let cols_min = case rows > 0 { - True -> int.min(cols_min, cols_curr) - options.tab_offset - False -> 0 - } - #(rows, cols_max, cols_min) + Size(rows: rows, columns: cols_max) } /// Get the width of a tab at the given column. @@ -498,7 +492,7 @@ using options: Options, ellipsis ellipsis: String, ) -> String { - let ellipsis_width = line_with(ellipsis, options) + 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... @@ -880,73 +874,204 @@ using options: Options, with space: String, ) -> String { - let #(str_rows, str_cols_max, str_cols_min) = do_measure(str, options) - let space_width = line_with(space, options) + let str_size = dimensions_with(str, options) - // horizontal align - we can skip this if every line is at least columns wide - let str = case bounding_box.columns > str_cols_min { - True -> { - let align = case alignment { - Left -> fn(line, line_width) { - let missing = div_up(bounding_box.columns - line_width, space_width) - line <> string.repeat(space, missing) - } - - Right -> { - let missing_left = - div_up(bounding_box.columns - str_cols_max, space_width) - let space_left = string.repeat(space, missing_left) - - fn(line, line_width) { - let missing = - { bounding_box.columns - line_width } / space_width - missing_left - - space_left <> line <> string.repeat(space, missing) - } - } - - Center -> { - let missing_total = - div_up(bounding_box.columns - str_cols_max, space_width) - let missing_left = missing_total / 2 - let space_left = string.repeat(space, missing_left) - let space_right = string.repeat(space, missing_total - missing_left) - - fn(line, line_width) { - let missing = - { bounding_box.columns - line_width } - / space_width - - missing_total - - space_left <> line <> string.repeat(space, missing) <> space_right - } - } - } - do_align(str, options, align) - } - False -> str + let left = case alignment { + Left -> 0 + Right -> int.max(bounding_box.columns - str_size.columns, 0) + Center -> int.max({ bounding_box.columns - str_size.columns } / 2, 0) } - // vertical align - we can also skip this if the string has more lines already - let missing_rows = bounding_box.rows - str_rows - case missing_rows > 0 { - True -> { - let space_row = - string.repeat(space, div_up(bounding_box.columns, space_width)) + let top = case placement { + Top -> 0 + Bottom -> int.max(bounding_box.rows - str_size.rows, 0) + Middle -> int.max({ bounding_box.rows - str_size.rows } / 2, 0) + } - case placement { - Top -> str <> string.repeat("\n" <> space_row, missing_rows) - Bottom -> string.repeat(space_row <> "\n", missing_rows) <> str - Middle -> { - let top = missing_rows / 2 - let bottom = missing_rows - top - string.repeat(space_row <> "\n", top) - <> str - <> string.repeat("\n" <> space_row, bottom) + let viewport_size = + Size( + rows: int.max(str_size.rows, bounding_box.rows), + columns: int.max(str_size.columns, bounding_box.columns), + ) + + do_viewport(str, str_size, viewport_size, top, left, options, space) +} + +/// 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 using = default_options + padding_with(str, top:, right:, bottom:, left:, using:, 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 str_size = dimensions_with(str, options) + // if any padding is negative, the target box will be smaller by that amount. + // I use (top, left) as the origin throughout this function to make it easier + // to figure out where I am in relation to the output string. + // If I am within (0, max_*), it means I should print these characters. + let viewport_size = + Size( + rows: str_size.rows + top + bottom, + columns: str_size.columns + left + right, + ) + do_viewport(str, str_size, viewport_size, top, left, options, space) +} + +type ViewportState { + ViewportState(buf: String, line: String, row: Int, col: Int) +} + +fn do_viewport( + str: String, + str_size: Size, + viewport_size: Size, + top: Int, + left: Int, + options: Options, + space: String, +) { + let Size(rows: str_rows, columns: _) = str_size + let Size(rows: max_row, columns: max_col) = viewport_size + + let space_width = spacer_width(options, space) + + // precompute all the padding strings that we can + let missing_left = div_up(int.max(0, left), space_width) + let padding_left = string.repeat(space, missing_left) + // no padding_right, since we need to pad the end of the line as well + + let push_line = fn(state: ViewportState) -> ViewportState { + let buf = case state.row < 0 || state.row >= max_row { + True -> state.buf + False -> { + let missing_right = div_up(max_col - state.col, space_width) + let padding_right = string.repeat(space, missing_right) + + case state.buf { + "" -> padding_left <> state.line <> padding_right + _ -> state.buf <> "\n" <> padding_left <> state.line <> padding_right } } } - False -> str + + ViewportState(buf:, line: "", row: state.row + 1, col: left) + } + + let state = ViewportState(buf: "", line: "", row: top, col: left) + let state = { + use state, piece, width <- fold_raw(str, options, state) + let width = case piece { + "\t" -> tab(options, state.col - left + options.tab_offset) + _ -> width + } + + // io.debug(#(state, piece, width)) + + case piece { + "\n" -> push_line(state) + _ -> + case + state.row < 0 + || state.row >= max_row + || state.col < 0 + || state.col >= max_col + { + True -> + case is_ansi_component(piece, options) { + True -> + ViewportState( + buf: state.buf, + line: state.line <> piece, + row: state.row, + col: state.col, + ) + + False -> + ViewportState( + buf: state.buf, + line: state.line, + row: state.row, + col: state.col + width, + ) + } + False -> + ViewportState( + buf: state.buf, + line: state.line <> piece, + row: state.row, + col: state.col + width, + ) + } + } + } + + // do not push a line with padding if the last line is empty + let str = case state.col > left { + True -> push_line(state).buf + False -> state.buf <> state.line + } + + // vertical padding + let padding_line = string.repeat(space, div_up(max_col, space_width)) + let padding_top = string.repeat(padding_line <> "\n", top) + let bottom = max_row - top - str_rows + let padding_bottom = string.repeat("\n" <> padding_line, bottom) + + // no string at all -> padding only, but no double newline, please + case state.row > top || state.col > left { + True -> padding_top <> str <> padding_bottom + False -> padding_top <> str <> string.drop_left(padding_bottom, 1) } } @@ -1001,7 +1126,7 @@ using options: Options, with space: String, ) -> String { - let space_width = line_with(space, options) + let space_width = spacer_width(options, space) use line, line_width <- do_align(str, options) let missing = div_up(max_width - line_width, space_width) @@ -1048,6 +1173,17 @@ /// Integer division, rounding up fn div_up(numerator: Int, denom: Int) { { numerator + denom - 1 } / denom +} + +/// 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 + _ -> line_with(str, options) + } } /// Make sure [SGR ansi codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters) @@ -1152,6 +1288,14 @@ /// /// 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 ↑ @@ -1165,7 +1309,8 @@ fold_with(str, default_options, state, fun) } -/// A higher-level `fold` that keeps track of the position inside of the string +/// 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. @@ -1273,6 +1418,14 @@ /// 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)] +/// ``` /// ///
/// diff --git a/test/columns.gleam b/test/columns.gleam --- a/test/columns.gleam +++ b/test/columns.gleam @@ -1,8 +1,7 @@ -import gleam/int import gleam/io import gleam/list import gleam/string -import string_width.{Left, Right, Size, Top} +import string_width.{type Size, Size} const max_width = 49 @@ -11,93 +10,77 @@ const left_width = 7 pub fn main() { - let options = - string_width.new() - //|> string_width.at_tab_offset(int.min(end_col, start_col)) - |> string_width.at_tab_offset(left_width + tab_width) - - string_width.limit_with( - "Nabis sit vel \u{1b}[31mpraesentium\u{1b}[m quod provident hic.\t\taliquid harum perferendis", - to: Size(rows: 500, columns: 40), - ellipsis: "", - using: options, - ) - |> string_width.tabs_to_spaces_with(options) - |> io.println - + "\t\tAbc\tDef" |> io.println + let left_width = 7 [ #( "\u{1b}[34mabcde\u{1b}[m", - // "Nihil dolorem eum \u{1b}[31mmagni\u{1b}[m ut et consectetur. Est nisi molestiae occecati iusto\t\taspernatur\tofficiis. Voluptate id dolor vel doloribus eligendi dignissimos recusandae. Consequatur et impedit maiores asperiores qui accusamus. Ea eum dolorem minima veniam suscipit dignissimos animi qui.", - "Nabis sit vel \u{1b}[31mpraesentium\u{1b}[m quod provident hic.\t\taliquid harum perferendis qui aut occaecati. Itaque veritatis repellat dolor qui. Ullam cum esse ad eos beatae qui suscipit sint. Nam sum similique culpa consectetur vel vel.", + "Nobis sit vel \u{1b}[31mpraesentium\u{1b}[m quod provident hic.\t\tAliquid harum perferendis qui aut occaecati. Itaque veritatis repellat dolor qui. Ullam cum esse ad eos beatae qui suscipit sint. Nam eum similique culpa consectetur vel vel.", ), #("xyz", "12345"), ] - |> list.map(fn(pair) { columnate(left: pair.0, right: pair.1) }) - |> string.join("\n") + |> list.map(with: fn(pair) { columnate(pair.0, pair.1, with: left_width) }) + |> string.join(with: "\n") |> io.println + // loop(10_000) } -fn loop(i: Int) { - case i > 0 { - True -> { - columnate( - left: "\u{1b}[34mhelp\u{1b}[m", - right: "Nihil dolorem eum \u{1b}[31mmagni\u{1b}[m ut et consectetur. Est nisi molestiae occaecati iusto\taspernatur\tofficiis. Voluptate id dolor vel doloribus eligendi dignissimos recusandae. Consequatur et impedit maiores asperiores qui accusamus. Ea eum dolorem minima veniam suscipit dignissimos animi qui.", - ) +fn loop(i) { + case i { + 0 -> Nil + _ -> { + [ + #( + "\u{1b}[34mabcde\u{1b}[m", + "Nobis sit vel \u{1b}[31mpraesentium\u{1b}[m quod provident hic.\t\tAliquid harum perferendis qui aut occaecati. Itaque veritatis repellat dolor qui. Ullam cum esse ad eos beatae qui suscipit sint. Nam eum similique culpa consectetur vel vel.", + ), + #("xyz", "12345"), + ] + |> list.map(with: fn(pair) { columnate(pair.0, pair.1, with: left_width) }) + |> string.join(with: "\n") loop(i - 1) } - False -> Nil } } -fn columnate(left left: String, right right: String) { - left - <> right - |> wrap(from: left_width + tab_width, to: max_width) - |> align(Left, within: max_width - left_width - tab_width, with: ".") - |> align(Right, within: max_width, with: "-") - |> fast_drop_left(string_width.line(left)) +fn columnate(left: String, right: String, with left_column_width: Int) -> String { + let right_column_start = left_column_width + tab_width + let right = + right + |> wrap(from: right_column_start, to: max_width) + |> string_width.inline_styles + |> string_width.padding(0, 0, 0, left: right_column_start, with: " ") + |> drop_left(up_to: left |> string_width.line) + + left <> right } -fn align( - str: String, - horizontally: string_width.Alignment, - within width: Int, - with with: String, -) { - let options = - string_width.new() - //|> string_width.at_tab_offset(int.min(end_col, start_col)) - |> string_width.at_tab_offset(left_width + tab_width) +fn drop_left(from x: String, up_to count: Int) -> String { + case count > 0 { + True -> + case x |> string.pop_grapheme { + Ok(#(_, x)) -> x |> drop_left(up_to: count - 1) + _else -> x + } + False -> x + } +} - string_width.position_with( - str, - with: with, - vertical: Top, - horizontal: horizontally, +fn wrap(x: String, from start_column: Int, to end_column: Int) -> String { + let #(start_column, end_column) = normalize_bounds(start_column, end_column) + let options = string_width.new() |> string_width.at_tab_offset(start_column) + + x + |> string_width.limit_with( + ellipsis: "...", + to: Size(rows: 500, columns: end_column - start_column), using: options, - in: Size(rows: 0, columns: width), ) } -fn wrap(str: String, from start_col: Int, to end_col: Int) { - let size = Size(rows: 500, columns: int.absolute_value(end_col - start_col)) - let options = - string_width.new() - //|> string_width.at_tab_offset(int.min(end_col, start_col)) - |> string_width.at_tab_offset(left_width + tab_width) - - string_width.limit_with(str, to: size, using: options, ellipsis: "...") -} - -fn fast_drop_left(str: String, count: Int) { - case count > 0 { - True -> - case string.pop_grapheme(str) { - Ok(#(_, str)) -> fast_drop_left(str, count - 1) - _ -> str - } - False -> str +fn normalize_bounds(start_column: Int, end_column: Int) -> #(Int, Int) { + case start_column { + n if n > end_column -> #(end_column, n) + _else -> #(start_column, end_column) } } diff --git a/test/layout_test.gleam b/test/layout_test.gleam --- a/test/layout_test.gleam +++ b/test/layout_test.gleam @@ -1,8 +1,8 @@ import gleam/string import gleeunit/should import string_width.{ - Bottom, Center, Left, Middle, Right, Size, Top, align, limit, position, - tabs_to_spaces, + Bottom, Center, Left, Middle, Right, Size, Top, align, limit, padding, + position, tabs_to_spaces, } pub fn limit_test() { @@ -134,6 +134,55 @@ position("Trans\nrights\nare\nhuman\nrights", Size(0, 7), Right, Top, " ") |> should.equal(" Trans \n rights\n are \n human \n rights") +} + +pub fn padding_test() { + padding("o", 1, 1, 1, 1, with: "x") + |> should.equal("xxx\nxox\nxxx") + + padding("hello", 0, 0, 0, right: -1, with: "") + |> should.equal("hell") + + padding("Trans\nrights!", top: 2, right: 5, bottom: 1, left: 1, with: "-") + |> should.equal( + "-------------\n-------------\n-Trans-------\n-rights!-----\n-------------", + ) + + padding("", 0, 0, 0, 0, " ") + |> should.equal("") + + padding("", 1, 1, 1, 1, "-") + |> should.equal("--\n--") + + padding("x", 1, 1, 1, 1, " ") + |> should.equal(" \n x \n ") + + padding("hello", 0, -1, 0, 0, "") + |> should.equal("hell") + + padding("hello", 0, 0, 0, -1, "") + |> should.equal("ello") + + padding("hello", 0, -1, 0, -1, "") + |> should.equal("ell") + + padding("hello", 0, -10, 0, -10, "") + |> should.equal("") + + padding("hello", 1, -10, 1, -10, "") + |> should.equal("\n\n") + + padding("hi", -1, 0, 0, 0, "") + |> should.equal("") + + padding("hi", -1, 0, -1, 0, "") + |> should.equal("") + + padding("\u{1b}[m", 1, 1, 1, 1, "-") + |> should.equal("--\n\u{1b}[m--") + + padding("\u{1b}[m", -10, -10, -10, -10, "-") + |> should.equal("\u{1b}[m") } pub fn align_test() { -- tangled.sh