From 8b353e94245b40d4cf9120429b54783ce7439d85 Mon Sep 17 00:00:00 2001 From: Joshua Reusch Date: Sat, 19 Oct 2024 12:55:36 +0200 Subject: [PATCH] THIS IS HUGE stack_horizontal, stack_vertical, update README and docs --- README.md | 114 +++++++++++- src/string_width.gleam | 393 +++++++++++++++++++++++++++++++++-------- test/columns.gleam | 89 ++++------ test/layout_test.gleam | 44 ++++- test/tour.gleam | 72 ++++++++ 5 files changed, 584 insertions(+), 128 deletions(-) create mode 100644 test/tour.gleam 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. [![Package Version](https://img.shields.io/hexpm/v/string_width)](https://hex.pm/packages/string_width) [![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](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!" +/// ``` +/// +///
+/// +/// Back to top ↑ +/// +///
+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. +/// +///
+/// +/// Back to top ↑ +/// +///
+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." +/// ``` +/// +///
+/// +/// Back to top ↑ +/// +///
+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. +/// +///
+/// +/// Back to top ↑ +/// +///
+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" +} -- 2.51.2