diff --git a/src/string_width.gleam b/src/string_width.gleam
index bd72928..08705c7 100644
--- 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 @@ import string_width/internal/tables
// 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 @@ fn do_get_terminal_size() -> Result(#(Int, Int), Nil)
///
///
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(str: String) -> Int {
///
///
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 line_with(str: String, options: Options) -> Int {
///
///
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(str: String) -> Size {
///
///
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 @@ fn do_measure(str: String, options: Options) -> #(Int, Int, Int) {
}
}
- 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 @@ fn do_measure(str: String, options: Options) -> #(Int, Int, Int) {
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 @@ pub fn limit_with(
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 @@ pub fn position_with(
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)
-
- // 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)
- }
+ let str_size = dimensions_with(str, options)
- Right -> {
- let missing_left =
- div_up(bounding_box.columns - str_cols_max, space_width)
- let space_left = string.repeat(space, missing_left)
+ 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)
+ }
- fn(line, line_width) {
- let missing =
- { bounding_box.columns - line_width } / space_width - missing_left
+ 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)
+ }
- space_left <> line <> string.repeat(space, missing)
- }
- }
+ let viewport_size =
+ Size(
+ rows: int.max(str_size.rows, bounding_box.rows),
+ columns: int.max(str_size.columns, bounding_box.columns),
+ )
- 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)
+ do_viewport(str, str_size, viewport_size, top, left, options, space)
+}
- fn(line, line_width) {
- let missing =
- { bounding_box.columns - line_width }
- / space_width
- - missing_total
+/// 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-------------"
+/// ```
+///
+///
+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)
+}
- space_left <> line <> string.repeat(space, missing) <> space_right
- }
+/// Like `padding`, but use custom options for measuring.
+///
+///
+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
}
}
- do_align(str, options, align)
}
- False -> str
+
+ ViewportState(buf:, line: "", row: state.row + 1, col: left)
}
- // 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))
-
- 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 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,
+ )
}
- }
}
- False -> str
+ }
+
+ // 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 @@ pub fn align_with(
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)
@@ -1050,6 +1175,17 @@ 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)
/// (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
@@ -1152,6 +1288,14 @@ pub type Piece {
///
/// 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 @@ pub fn fold(
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.
@@ -1274,6 +1419,14 @@ pub fn fold_raw(
/// 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 ↑
diff --git a/test/columns.gleam b/test/columns.gleam
index 9cb1db6..799eae6 100644
--- 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 tab_width = 2
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)
-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)
+ left <> right
+}
- string_width.position_with(
- str,
- with: with,
- vertical: Top,
- horizontal: horizontally,
- using: options,
- in: Size(rows: 0, columns: 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
+ }
}
-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)
+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)
- string_width.limit_with(str, to: size, using: options, ellipsis: "...")
+ x
+ |> string_width.limit_with(
+ ellipsis: "...",
+ to: Size(rows: 500, columns: end_column - start_column),
+ using: options,
+ )
}
-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
index a2efb2f..77b105e 100644
--- 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() {
@@ -136,6 +136,55 @@ pub fn position_test() {
|> 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() {
align("hello", to: 10, align: Left, with: " ")
|> should.equal("hello ")