diff --git a/README.md b/README.md
index 8f074d0..e2157c2 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,5 @@
# string_width
+### A low-ish level library for building terminal UIs.
[](https://hex.pm/packages/string_width)
[](https://hexdocs.pm/string_width/)
@@ -7,14 +8,121 @@
gleam add string_width@3
```
-A small package estimating the required number of cells when printing a string on the terminal. It supports all Gleam targets, handles ANSI escape codes, includes useful layout functions, and passes the tests of the NPM [string-width](https://www.npmjs.com/package/string-width) package, among others.
+`string_width` provides functions to measure the required amount of cells, and then layout strings in an ANSI-aware manner. It includes ready-to-use layout functions like [limit](./string_width.html#limit "Word wrapping and truncation") or [stack_horizontal](./string_width.html#stack_horizontal "Build column-based layouts"), as well as low-level primitives, allowing you to build your own layout algorithms.
+
+All Gleam targets are supported, and it passes the tests of the NPM [string-width](https://www.npmjs.com/package/string-width) and [unicode-width](https://crates.io/crates/unicode-width) crate where applicable. Compared to others, a heavy focus is put on on reporting the actual width of strings in terminals instead (Check out [Internals](./internals.html)) for more information.)
It is also one of the fastest options available, even including target-specific ones, while still providing you full flexibility and correctness.
+### Tour
+
```gleam
-import string_width
+import gleam/int
+import gleam/io
+import gleam/list
+import gleam/result
+import string_width.{Left, Size, Top}
+
+// we will render a simple help menu for a command-line app here.
+// the help menu will automatically adjust based on the width of the terminal.
+
+type Command {
+ Command(name: String, description: String)
+}
+
+const commands = [
+ Command(
+ name: "build",
+ description: "Build and bundle your application.\nThe generated bundle calls your apps' main function. If your main function accepts a single argument of type List(String), all additional command-line arguments will be passed there.",
+ ),
+ Command(
+ name: "dev",
+ description: "Start a file watcher that automatically recompiles your app on all file changes, and then re-runs your tests. Compilation errors or test failures will be displayed in an overlay, and if everything succeeds, your window will automatically hot-reload.",
+ ),
+ Command(
+ name: "help",
+ description: "Show this help text."
+ ),
+]
pub fn main() {
+ // get the size of the terminal, or fallback to a default size.
+ let term_size =
+ string_width.get_terminal_size()
+ |> result.unwrap(Size(rows: 80, columns: 24))
+
+ // how big does the left-hand side need to be?
+ let left_width =
+ list.fold(commands, 0, fn(max, cmd) {
+ int.max(max, string_width.line(cmd.name))
+ })
+
+ // add some gap between both columns and compute the size of the right side.
+ let gap = 4
+ // set a maximum width of 60 for the right column to improve readability.
+ let right_width = int.min(60, term_size.columns - left_width - gap)
+
+ commands
+ |> list.map(fn(cmd) {
+ [
+ cmd.name
+ |> pink
+ // make sure all left-hand sides are padded to left_width
+ |> string_width.align(left_width, Left, with: " "),
+ // word wrap the right-hand side such that it fits into our column.
+ // if the description would overflow 10 lines, truncate it.
+ cmd.description
+ |> string_width.limit(
+ to: Size(rows: 10, columns: right_width),
+ ellipsis: "…",
+ )
+ // make sure that if we used styles those would wrap properly
+ |> string_width.inline_styles,
+ ]
+ // build the column layout;
+ // the name should be aligned with the top of the description
+ |> string_width.stack_horizontal(place: Top, gap:, with: " ")
+ })
+ // combine the help text of all commands, adding a line of space in between.
+ |> string_width.stack_vertical(align: Left, gap: 1, with: " ")
+ |> io.println
+}
+
+// string_width is agnostic to the way you add styles to your strings.
+// maybe check out gleam_community_ansi!
+fn pink(str: String) {
+ "\u{1b}[38;5;207m" <> str <> "\u{1b}[m"
+}
+```
+
+Output:
+
+
build Build and bundle your application.
+ The generated bundle calls your apps' main function. If your
+ main function accepts a single argument of type
+ List(String), all additional command-line arguments will be
+ passed there.
+dev Start a file watcher that automatically recompiles your app
+ on all file changes, and then re-runs your tests.
+ Compilation errors or test failures will be displayed in an
+ overlay, and if everything succeeds, your window will
+ automatically hot-reload.
+help Show this help text.
+
+
+### Measuring strings
+
+```gleam
string_width.dimensions("hello,\n안녕하세요")
// --> string_width.Size(rows: 2, columns: 10)
@@ -61,9 +169,9 @@ pub fn main() {
The table lookup technique used by this library is heavily based on the musl libc `wcwidth` implementation. I built updated tables using the Unicode 16.0 data, and added support for ambiguous characters. It also uses the regex of the ansi-regex npm package, and the test cases of the string-width npm package.
-
- **Grapheme Clusters in Terminals:** [https://mitchellh.com/writing/grapheme-clusters-in-terminals](https://mitchellh.com/writing/grapheme-clusters-in-terminals)
- **UAX #11 East Asian Width:** [https://www.unicode.org/reports/tr11/](https://www.unicode.org/reports/tr11/)
- **Terminal Unicode Core**: [https://github.com/contour-terminal/terminal-unicode-core](https://github.com/contour-terminal/terminal-unicode-core)
- **string-width:** [https://github.com/sindresorhus/string-width](https://github.com/sindresorhus/string-width)
- **musl-libc wcwidth:** [https://git.musl-libc.org/cgit/musl/tree/src/ctype/wcwidth.c](https://git.musl-libc.org/cgit/musl/tree/src/ctype/wcwidth.c)
+- **reflow:** [https://github.com/muesli/reflow](https://github.com/muesli/reflow)
diff --git a/src/string_width.gleam b/src/string_width.gleam
index 3d646ae..2ec3c44 100644
--- a/src/string_width.gleam
+++ b/src/string_width.gleam
@@ -1,35 +1,54 @@
-//// The `with` variants of function additionally work with custom options.
+////
+////
+//// The `with` variants of all functions additionally work with custom options.
+////
+//// **Tip:** Hover the links for short summaries!
////
//// #### Measure
-//// [line](#line)[[_with](#line_with)]
-//// [dimensions](#dimensions)[[_with](#dimensions_with)],
-//// [get_terminal_size](#get_terminal_size)
+//// [line](#line "Width of a line")[[_with](#line_with)]
+//// [dimensions](#dimensions "Size of a string")[[_with](#dimensions_with)],
+//// [get_terminal_size](#get_terminal_size "Size of the terminal window")
+////
+//// #### Layout & Positioning
+////
+//// [limit](#limit "Word wrapping and truncation")[[_with](#limit_with)],
+//// [align](#align "Text alignment")[[_with](#align_with)],
+//// [tabs_to_spaces](#tabs_to_spaces "Pin tab widths")[[_with](#tabs_to_spaces_with)]
////
-//// #### Layout
-//// [limit](#limit)[[_with](#limit_with)],
-//// [align](#align)[[_with](#align_with)],
-//// [padding](#padding)[[_with](#padding_with)],
-//// [position](#position)[[_with](#position_with)],
-//// [scroll](#scroll)[[_with](#scroll_with)],
-//// [tabs_to_spaces](#tabs_to_spaces)[[_with](#tabs_to_spaces_with)]
+//// [stack_horizontal](#stack_horizontal "Column-based layouts")[[_with](#stack_horizontal_with)],
+//// [stack_vertical](#stack_vertical "Row-based layouts")[[_with](#stack_vertical_with)],
+//// [padding](#padding "Add extra space around a string")[[_with](#padding_with)],
+//// [position](#position "Put a string inside a fixed-size box")[[_with](#position_with)],
+//// [scroll](#scroll "Cutout a viewport area from a string")[[_with](#scroll_with)]
////
//// #### ANSI escape sequence helpers
-//// [inline_styles](#inline_styles),
-//// [strip_ansi](#strip_ansi),
-//// [is_ansi_component](#is_ansi_component)
+//// [inline_styles](#inline_styles "Ensure styling doesn't overflow lines/columns"),
+//// [strip_ansi](#strip_ansi "Remove all styles"),
+//// [is_ansi_component](#is_ansi_component "Helper for fold")
////
//// #### Options Builder
-//// [new](#new),
-//// [ambiguous_as_wide](#ambiguous_as_wide), [count_ansi_codes](#count_ansi_codes),
-//// [mode_2027](#mode_2027), [mode_2027_ext](#mode_2027_ext), [mode_wcwidth](#mode_wcwidth),
-//// [at_tab_offset](#at_tab_offset), [with_tab_width](#with_tab_width)
+//// [new](#new "Default options"),
+//// [ambiguous_as_wide](#ambiguous_as_wide "CJK support"),
+//// [count_ansi_codes](#count_ansi_codes "Non-terminal support"),
+//// [mode_2027](#mode_2027 "Compatibility with other libraries"),
+//// [mode_2027_ext](#mode_2027_ext "Compatibility with other libraries with extra salt"),
+//// [mode_wcwidth](#mode_wcwidth "Default mode, use this for terminals!"),
+//// [at_tab_offset](#at_tab_offset "Control tab width calculations"),
+//// [with_tab_width](#with_tab_width "Control tab width calculations")
////
//// #### Advanced
-//// [fold](#fold)[[_with](#fold_with)],
-//// [fold_raw](#fold_raw), [fold_raw_pieces](#fold_raw_pieces),
-////
+//// [fold](#fold "Loop grapheme clusters and their position/size")[[_with](#fold_with)],
+//// [fold_raw](#fold_raw "Loop low-level segments and their un-processed width"),
+//// [fold_raw_pieces](#fold_raw_pieces "Loop low-level segments and their position/size")
////
+// can we talk about how weird it is that I only used 3 things from the stdlib??
+// what's up with that?
import gleam/int
import gleam/list
import gleam/string
@@ -50,31 +69,25 @@ import string_width/internal/tables
// https://github.com/muesli/reflow/
// https://github.com/charmbracelet/x/
-// v3.2.0:
-// x collect/reset SGR codes like reflow does
-// x padding layout, switch position to use padding internally
-// x remove do_measure
-// x make sure position -> padding only measures the string once
-// x rewrite align (think about trimming)
-// x make align trim lines? no.
-// - join/columns layout (grid? table?)
-// - join_vertical might also be intereseting for alignment/spacing
-// x ansi module
-// x drop_left/drop_right - padding with negative margins?
-// x 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
-// x explore skipping multiple bytes at a time like OTP json (8 bytes check if interesting -> continue)
+// v3.2.1:
// - try poslen -> split for binary states (align, limit)
+// so far, switching away from string.append was never worth it.
// - cprof, eprof, eflamb`e
// - propose a @inline attribute
// - propose changing the js codegen to generate constructor calls instead of withFields
+// v3.3.0:
+// - what about grid??
+// I don't have a design right now that I like.
+// - `length` variant for measure that works more like the old line
+// Why did I think this was useful?
+
// v4.0.0:
// - fix labeled arguments in position_with
// - think _again_ about changing fold to be called with an EOS marker at the end
-// - remove max_width from align
+// - remove max_width from align?
+// on the other hand, it makes sense that you would usually want to align
+// strings in a pre-determined box.
// - actually, replace align with align_lines
// - position with overflow-hidden? we have scroll now.
// - limit -> reflow/fit that doesn't respect newlines in the original string
@@ -1103,10 +1116,198 @@ pub fn scroll_with(
box(str, size, top:, left:, width:, height:, using: options, with: space)
}
-type ViewportState {
- ViewportState(buf: String, line: String, row: Int, col: Int)
+/// Stack multiple blocks of strings on top of each other into multiple rows.
+///
+/// The resulting string will as wide as the widest string in the list. All other
+/// blocks can be `position`-ed horizontally relative to this size.
+///
+/// ### Example
+///
+/// ```gleam
+/// [
+/// "Hello!",
+/// "I hope you are feeling fantastic today!"
+/// ]
+/// |> stack_vertical(Center, gap: 1, with: " ")
+/// // --> " Hello! \n"
+/// // <> " \n"
+/// // <> "I hope you are feeling fantastic today!"
+/// ```
+///
+///
+pub fn stack_vertical(
+ blocks: List(String),
+ align horizontal: Alignment,
+ gap gap: Int,
+ with space: String,
+) -> String {
+ stack_vertical_with(blocks, horizontal, gap, default_options, space)
+}
+
+/// Like `stack_vertical`, but using custom options for measure.
+///
+///
+pub fn stack_vertical_with(
+ blocks: List(String),
+ align horizontal: Alignment,
+ gap gap: Int,
+ using options: Options,
+ with space: String,
+) -> String {
+ use first, blocks <- guard_list(blocks, "")
+
+ let first_size = dimensions_with(first, options)
+ let measured = {
+ use block <- list.map(blocks)
+ #(block, dimensions_with(block, options))
+ }
+
+ let width = {
+ use max, #(_, Size(_, width)) <- list.fold(measured, first_size.columns)
+ int.max(max, width)
+ }
+
+ let space_width = spacer_width(options, space)
+ let gap_line = string.repeat(space, div_up(width, space_width))
+ let gap_str = string.repeat("\n" <> gap_line, gap)
+
+ let render = fn(block: String, size: Size) {
+ let left = case horizontal {
+ Left -> 0
+ Right -> size.columns - width
+ Center -> { size.columns - width } / 2
+ }
+
+ box(block, size, 0, left, size.rows, width, options, space)
+ }
+
+ use buf, #(block, size) <- list.fold(measured, render(first, first_size))
+ buf <> gap_str <> "\n" <> render(block, size)
+}
+
+/// Stack multiple blocks of strings horizontally next to each other into
+/// multiple columns.
+///
+/// The resulting string will as high as the highest string in the list. All
+/// other blocks can be `position`-ed vertically relative to this size.
+///
+/// **Tip;** Use [limit](#limit) and [inline_styles](#inline_styles) to lay out
+/// the text in your columns!
+///
+/// ### Example
+///
+/// ```gleam
+/// [
+/// "--color",
+/// "Enable color support.\nThis option is enabled by default in a terminal."
+/// ]
+/// |> stack_horizontal(place: Top, gap: 4, with: " ")
+/// // --> "--color Enable color support. \n"
+/// // <> " This option is enabled by default in a terminal."
+/// ```
+///
+///
+pub fn stack_horizontal(
+ blocks: List(String),
+ place vertical: Placement,
+ gap gap: Int,
+ with space: String,
+) -> String {
+ stack_horizontal_with(blocks, vertical, gap, default_options, space)
+}
+
+/// Like `stack_horizontal`, but using custom options for measure.
+///
+///
+pub fn stack_horizontal_with(
+ blocks: List(String),
+ place vertical: Placement,
+ gap gap: Int,
+ using options: Options,
+ with space: String,
+) -> String {
+ use first, blocks <- guard_list(blocks, "")
+
+ let first_size = dimensions_with(first, options)
+ let measured = {
+ use block <- list.map(blocks)
+ #(block, dimensions_with(block, options))
+ }
+
+ // min height is 1, in case you pass all empty strings.
+ let height =
+ int.max(1, {
+ use max, #(_, Size(height, _)) <- list.fold(measured, first_size.rows)
+ int.max(max, height)
+ })
+
+ let space_width = spacer_width(options, space)
+ let gap_str = string.repeat(space, div_up(gap, space_width))
+
+ let render = fn(lines: List(String), block: String, size: Size, push_column) {
+ // we use view_fold here to build all lines "in parallel"
+ let push = fn(state, line, line_width) {
+ case state {
+ #([line_so_far, ..rest_input], output) -> {
+ let missing_right = div_up(size.columns - line_width, space_width)
+ let padding_right = string.repeat(space, missing_right)
+
+ let line = push_column(line_so_far, line <> padding_right)
+ #(rest_input, [line, ..output])
+ }
+ _ -> state
+ }
+ }
+
+ let top = case vertical {
+ Top -> 0
+ Bottom -> size.rows - height
+ Middle -> { size.rows - height } / 2
+ }
+
+ let bottom = top + height
+ let right = size.columns
+
+ let #(_, formatted_lines) =
+ view_fold(block, size, top, 0, bottom, right, options, #(lines, []), push)
+
+ list.reverse(formatted_lines)
+ }
+
+ // render first block
+ let lines = {
+ use _, line <- render(list.repeat("", height), first, first_size)
+ line
+ }
+
+ // render rest of the blocks
+ let lines = {
+ use lines, #(block, size) <- list.fold(measured, lines)
+ use line_so_far, line <- render(lines, block, size)
+ line_so_far <> gap_str <> line
+ }
+
+ string.join(lines, with: "\n")
}
+/// cutout a viewport of a string in a rectangular manner, filling the remaining
+/// space with the spacer. Internal version of scroll or padding.
fn box(
str: String,
str_size: Size,
@@ -1119,8 +1320,7 @@ fn box(
) {
let space_width = spacer_width(options, space)
- let initial_col = -1 * left
- let missing_left = div_up(int.max(0, initial_col), space_width)
+ let missing_left = div_up(int.max(0, -left), space_width)
let padding_left = string.repeat(space, missing_left)
let bottom = top + height
@@ -1133,9 +1333,16 @@ fn box(
padding_left <> line <> padding_right
}
+type ViewState(state) {
+ ViewState(acc: state, line: String, row: Int, col: Int)
+}
+
+/// cutout a viewport of a string.
+/// the passed align function can pad the string to the right length, etc.
+/// `align` uses a different align function to box.
fn view(
str: String,
- str_size: Size,
+ size: Size,
top top: Int,
left left: Int,
bottom bottom: Int,
@@ -1143,26 +1350,55 @@ fn view(
using options: Options,
align align: fn(String, Int) -> String,
) {
+ let push = fn(buf, line, line_width) {
+ buf <> "\n" <> align(line, line_width)
+ }
+
+ let result = view_fold(str, size, top, left, bottom, right, options, "", push)
+
+ case result {
+ "" -> result
+ _ -> {
+ // TODO: this is terrible.
+ let #(_, result) = unsafe_split(result, 1)
+ result
+ }
+ }
+}
+
+/// collect a viewport/cutout of a string into a custom data structure.
+/// push is supposed to pad/align/etc however it sees fit.
+/// `stack_horizontal` uses this to collect all lines in parallel.
+fn view_fold(
+ str: String,
+ str_size: Size,
+ top top: Int,
+ left left: Int,
+ bottom bottom: Int,
+ right right: Int,
+ using options: Options,
+ from initial: state,
+ push push: fn(state, String, Int) -> state,
+) -> state {
let Size(rows: str_rows, columns: _) = str_size
+
let line_width_offset = int.max(0, left)
- let push_line = fn(state: ViewportState) -> ViewportState {
- let buf = case state.row < top || state.row >= bottom {
- True -> state.buf
+ let push_line = fn(state: ViewState(state)) -> ViewState(state) {
+ case state.row < top || state.row >= bottom {
+ True -> {
+ ViewState(acc: state.acc, line: state.line, row: state.row + 1, col: 0)
+ }
False -> {
- let line = align(state.line, state.col - line_width_offset)
- case state.buf {
- "" -> line
- _ -> state.buf <> "\n" <> line
- }
+ let acc = push(state.acc, state.line, state.col - line_width_offset)
+ ViewState(acc:, line: "", row: state.row + 1, col: 0)
}
}
-
- ViewportState(buf:, line: "", row: state.row + 1, col: 0)
}
- let state = ViewportState(buf: "", line: "", row: 0, col: 0)
+ // vertical padding top
+ let acc = repeat(-top, initial, push(_, "", 0))
let state = {
- use state, piece, width <- fold_raw(str, options, state)
+ use state, piece, width <- fold_raw(str, options, ViewState(acc, "", 0, 0))
let width = case piece {
"\t" -> tab(options, state.col + options.tab_offset)
_ -> width
@@ -1180,24 +1416,24 @@ fn view(
True ->
case is_ansi_component(piece, options) {
True ->
- ViewportState(
- buf: state.buf,
+ ViewState(
+ acc: state.acc,
line: state.line <> piece,
row: state.row,
col: state.col,
)
False ->
- ViewportState(
- buf: state.buf,
+ ViewState(
+ acc: state.acc,
line: state.line,
row: state.row,
col: state.col + width,
)
}
False ->
- ViewportState(
- buf: state.buf,
+ ViewState(
+ acc: state.acc,
line: state.line <> piece,
row: state.row,
col: state.col + width,
@@ -1207,21 +1443,15 @@ fn view(
}
// do not push a line with padding if the last line is empty
- let str = case state.col > 0 {
- True -> push_line(state).buf
- False -> state.buf <> state.line
+ let acc = case state.line == "" && state.col == 0 {
+ True -> state.acc
+ False -> push(state.acc, state.line, state.col - line_width_offset)
}
- // vertical padding
- let padding_line = align("", 0)
- let padding_top = string.repeat(padding_line <> "\n", -top)
- let padding_bottom = string.repeat("\n" <> padding_line, bottom - str_rows)
+ // vertical padding bottom
+ let acc = repeat(bottom - str_rows, acc, push(_, "", 0))
- // no string at all -> padding only, but no double newline, please
- case state.row > 0 || state.col > 0 {
- True -> padding_top <> str <> padding_bottom
- False -> padding_top <> str <> string.drop_left(padding_bottom, 1)
- }
+ acc
}
/// Integer division, rounding up
@@ -1229,6 +1459,14 @@ fn div_up(numerator: Int, denom: Int) {
{ numerator + denom - 1 } / denom
}
+/// do something, multiple times.
+fn repeat(times: Int, from state: state, do fun: fn(state) -> state) {
+ case times > 0 {
+ True -> repeat(times - 1, fun(state), fun)
+ False -> state
+ }
+}
+
/// line_with, but with a few fast paths for commmonly used strings.
fn spacer_width(options: Options, str: String) -> Int {
case str {
@@ -1236,10 +1474,19 @@ fn spacer_width(options: Options, str: String) -> Int {
" " -> 1
"..." -> 3
"…" -> 1
+ "0" -> 1
_ -> line_with(str, options)
}
}
+/// early return on empty lists.
+fn guard_list(list: List(a), empty: b, non_empty: fn(a, List(a)) -> b) -> b {
+ case list {
+ [] -> empty
+ [head, ..tail] -> non_empty(head, tail)
+ }
+}
+
// -- ANSI HELPERS -------------------------------------------------------------
/// Make sure [SGR ansi codes](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_(Select_Graphic_Rendition)_parameters)
diff --git a/test/columns.gleam b/test/columns.gleam
index 799eae6..9122b20 100644
--- a/test/columns.gleam
+++ b/test/columns.gleam
@@ -1,7 +1,6 @@
import gleam/io
import gleam/list
-import gleam/string
-import string_width.{type Size, Size}
+import string_width.{type Size, Left, Size, Top}
const max_width = 49
@@ -10,8 +9,6 @@ const tab_width = 2
const left_width = 7
pub fn main() {
- "\t\tAbc\tDef" |> io.println
- let left_width = 7
[
#(
"\u{1b}[34mabcde\u{1b}[m",
@@ -19,8 +16,23 @@ pub fn main() {
),
#("xyz", "12345"),
]
- |> list.map(with: fn(pair) { columnate(pair.0, pair.1, with: left_width) })
- |> string.join(with: "\n")
+ |> list.map(with: fn(pair) {
+ let left =
+ pair.0
+ |> string_width.position(Size(0, left_width), Left, Top, " ")
+
+ let right =
+ pair.1
+ |> string_width.limit(
+ to: Size(500, max_width - tab_width - left_width),
+ ellipsis: "...",
+ )
+ |> string_width.inline_styles
+
+ [left, right]
+ |> string_width.stack_horizontal(place: Top, gap: tab_width, with: " ")
+ })
+ |> string_width.stack_vertical(align: Left, gap: 1, with: " ")
|> io.println
// loop(10_000)
}
@@ -36,51 +48,28 @@ fn loop(i) {
),
#("xyz", "12345"),
]
- |> list.map(with: fn(pair) { columnate(pair.0, pair.1, with: left_width) })
- |> string.join(with: "\n")
- loop(i - 1)
- }
- }
-}
-
-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 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
- }
-}
+ |> list.map(with: fn(pair) {
+ let left =
+ pair.0
+ |> string_width.position(Size(0, left_width), Left, Top, " ")
-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)
+ let right =
+ pair.1
+ |> string_width.limit(
+ to: Size(500, max_width - tab_width - left_width),
+ ellipsis: "...",
+ )
+ |> string_width.inline_styles
- x
- |> string_width.limit_with(
- ellipsis: "...",
- to: Size(rows: 500, columns: end_column - start_column),
- using: options,
- )
-}
-
-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)
+ string_width.stack_horizontal(
+ [left, right],
+ place: Top,
+ gap: tab_width,
+ with: " ",
+ )
+ })
+ |> string_width.stack_vertical(align: Left, gap: 0, with: " ")
+ loop(i - 1)
+ }
}
}
diff --git a/test/layout_test.gleam b/test/layout_test.gleam
index 6cc05eb..99d582b 100644
--- a/test/layout_test.gleam
+++ b/test/layout_test.gleam
@@ -2,7 +2,7 @@ import gleam/string
import gleeunit/should
import string_width.{
Bottom, Center, Left, Middle, Right, Size, Top, align, limit, padding,
- position, scroll, tabs_to_spaces,
+ position, scroll, stack_horizontal, stack_vertical, tabs_to_spaces,
}
pub fn limit_test() {
@@ -179,7 +179,7 @@ pub fn padding_test() {
|> should.equal("")
padding("\u{1b}[m", 1, 1, 1, 1, "-")
- |> should.equal("--\n\u{1b}[m--")
+ |> should.equal("--\n-\u{1b}[m-\n--")
padding("\u{1b}[m", -10, -10, -10, -10, "-")
|> should.equal("\u{1b}[m")
@@ -235,3 +235,43 @@ pub fn inline_styles_test() {
string_width.inline_styles("\u{1b}[31mhell\u{1b}[0mo,\nworld!")
|> should.equal("\u{1b}[31mhell\u{1b}[0mo,\nworld!")
}
+
+pub fn stack_vertical_test() {
+ ["Hello!", "I hope you are feeling fantastic today!"]
+ |> stack_vertical(Center, gap: 1, with: " ")
+ |> should.equal(
+ " Hello! \n"
+ <> " \n"
+ <> "I hope you are feeling fantastic today!",
+ )
+
+ stack_vertical([], Center, 0, "")
+ |> should.equal("")
+
+ stack_vertical([""], Center, 0, "")
+ |> should.equal("")
+
+ stack_vertical(["", ""], Center, 0, "")
+ |> should.equal("\n")
+}
+
+pub fn stack_horizontal_test() {
+ [
+ "--color",
+ "Enable color support.\nThis option is enabled by default in a terminal.",
+ ]
+ |> stack_horizontal(place: Top, gap: 4, with: " ")
+ |> should.equal(
+ "--color Enable color support. \n"
+ <> " This option is enabled by default in a terminal.",
+ )
+
+ stack_horizontal([], Middle, 0, "")
+ |> should.equal("")
+
+ stack_horizontal([""], Middle, 1, " ")
+ |> should.equal("")
+
+ stack_horizontal(["", ""], Middle, 1, " ")
+ |> should.equal(" ")
+}
diff --git a/test/tour.gleam b/test/tour.gleam
new file mode 100644
index 0000000..822e983
--- /dev/null
+++ b/test/tour.gleam
@@ -0,0 +1,72 @@
+import gleam/int
+import gleam/io
+import gleam/list
+import gleam/result
+import string_width.{Left, Size, Top}
+
+// we will render a simple help menu for a command-line app here.
+// the help menu will automatically adjust based on the width of the terminal.
+
+type Command {
+ Command(name: String, description: String)
+}
+
+const commands = [
+ Command(
+ name: "build",
+ description: "Build and bundle your application.\nThe generated bundle calls your apps' main function. If your main function accepts a single argument of type List(String), all additional command-line arguments will be passed there.",
+ ),
+ Command(
+ name: "dev",
+ description: "Start a file watcher that automatically recompiles your app on all file changes, and then re-runs your tests. Compilation errors or test failures will be displayed in an overlay, and if everything succeeds, your window will automatically hot-reload.",
+ ), Command(name: "help", description: "Show this help text."),
+]
+
+pub fn main() {
+ // get the size of the terminal, or fallback to a default size.
+ let term_size =
+ string_width.get_terminal_size()
+ |> result.unwrap(Size(rows: 80, columns: 24))
+
+ // how big does the left-hand side need to be?
+ let left_width =
+ list.fold(commands, 0, fn(max, cmd) {
+ int.max(max, string_width.line(cmd.name))
+ })
+
+ // add some gap between both columns and compute the size of the right side.
+ let gap = 4
+ // set a maximum width of 60 for the right column to improve readability.
+ let right_width = int.min(60, term_size.columns - left_width - gap)
+
+ commands
+ |> list.map(fn(cmd) {
+ [
+ cmd.name
+ |> pink
+ // make sure all left-hand sides are padded to left_width
+ |> string_width.align(left_width, Left, with: " "),
+ // word wrap the right-hand side such that it fits into our column.
+ // if the description would overflow 10 lines, truncate it.
+ cmd.description
+ |> string_width.limit(
+ to: Size(rows: 10, columns: right_width),
+ ellipsis: "…",
+ )
+ // make sure that if we used styles those would wrap properly
+ |> string_width.inline_styles,
+ ]
+ // build the column layout;
+ // the name should be aligned with the top of the description
+ |> string_width.stack_horizontal(place: Top, gap:, with: " ")
+ })
+ // combine the help text of all commands, adding a line of space in between.
+ |> string_width.stack_vertical(align: Left, gap: 1, with: " ")
+ |> io.println
+}
+
+// string_width is agnostic to the way you add styles to your strings.
+// maybe check out gleam_community_ansi!
+fn pink(str: String) {
+ "\u{1b}[38;5;207m" <> str <> "\u{1b}[m"
+}