Something went wrong. Try again.
Measure the width of text in the terminal and build simple layouts!
Something went wrong. Try again.
74 kB · 2355 lines
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356//// <style>//// small {//// font-size: 0.7em;//// opacity: 0.75;//// }//////// .goto-top {//// display: block;//// font-size: 0.85em;//// text-align: right;//// }//// </style>//// <script>//// (callback => document.readyState !== 'loading' ? callback() : document.addEventListener('DOMContentLoaded', callback, { once: true }))(() => {//// const list = document.querySelector('.sidebar > ul:last-of-type')//// const sortedLists = document.createDocumentFragment()//// const sortedMembers = document.createDocumentFragment()//////// for (const header of document.querySelectorAll('main > h4')) {//// sortedLists.append((() => {//// const node = document.createElement('h3')//// node.append(header.textContent)//// return node//// })())//// sortedMembers.append((() => {//// const node = document.createElement('h2')//// node.append(header.textContent)//// return node//// })())//////// const sortedList = document.createElement('ul')//// sortedLists.append(sortedList)//////// for (//// let paragraph = header.nextElementSibling;//// paragraph.nodeName === 'P';//// paragraph = paragraph.nextElementSibling//// ) {//// for (const anchor of paragraph.querySelectorAll('a')) {//// const href = anchor.getAttribute('href')//// const member = document.querySelector(`.member:has(h2 > a[href="${href}"])`)//// const sidebar = list.querySelector(`li:has(a[href="${href}"])`)//// sortedList.append(sidebar)//// sortedMembers.append(member)//// }//// }//// }//////// document.querySelector('.sidebar').insertBefore(sortedLists, list)//// document.querySelector('.module-members:has(#module-values)').insertBefore(sortedMembers, document.querySelector('#module-values').nextSibling)//////// const goToTop = document.createElement('a')//// goToTop.classList.add('goto-top')//// goToTop.setAttribute('href', '#')//// goToTop.textContent = 'Back to top ↑'//// for (const member of document.querySelectorAll('.member')) {//// member.insertBefore(goToTop.cloneNode(true), null)//// }//// })//// </script>//////////// The `with` variants of all functions additionally work with custom options.//////// **Tip:** Hover the links for short summaries!//////// #### Measure//// [line](#line "Width of a line")<small>[[_with](#line_with)]</small>//// [dimensions](#dimensions "Size of a string")<small>[[_with](#dimensions_with)]</small>,//// [get_terminal_size](#get_terminal_size "Size of the terminal window"),//// [is_tty](#is_tty "Check if this is an interactive terminal")//////// #### Layout & Positioning//////// [limit](#limit "Word wrapping and truncation")<small>[[_with](#limit_with)]</small>,//// [soft_wrap](#soft_wrap "Word wrapping with custom indentation logic")<small>[[_with](#soft_wrap_with)]</small>,//// [align](#align "Text alignment")<small>[[_with](#align_with)]</small>,//// [tabs_to_spaces](#tabs_to_spaces "Pin tab widths")<small>[[_with](#tabs_to_spaces_with)]</small>//////// [stack_horizontal](#stack_horizontal "Column-based layouts")<small>[[_with](#stack_horizontal_with)]</small>,//// [stack_vertical](#stack_vertical "Row-based layouts")<small>[[_with](#stack_vertical_with)]</small>,//// [padding](#padding "Add extra space around a string")<small>[[_with](#padding_with)]</small>,//// [position](#position "Put a string inside a fixed-size box")<small>[[_with](#position_with)]</small>,//// [scroll](#scroll "Cutout a viewport area from a string")<small>[[_with](#scroll_with)]</small>//////// #### ANSI escape sequence helpers//// [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 "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 "Loop grapheme clusters and their position/size")<small>[[_with](#fold_with)]</small>,//// [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/intimport gleam/listimport gleam/stringimport string_width/internal/ansiimport string_width/internal/tables
// sources:// UAX #11 east asian width: https://www.unicode.org/reports/tr11/// Grapheme Clusters in Terminals: https://mitchellh.com/writing/grapheme-clusters-in-terminals// get-east-asian-width: https://github.com/sindresorhus/get-east-asian-width// string-width: https://github.com/sindresorhus/string-width// ansi-regex: https://github.com/chalk/ansi-regex// fast-string-truncated-width: https://github.com/fabiospampinato/fast-string-truncated-width/// musl-libc wcwidth: https://git.musl-libc.org/cgit/musl/tree/src/ctype/wcwidth.c// Mode 2027: https://github.com/contour-terminal/terminal-unicode-core// https://gitlab.freedesktop.org/terminal-wg/specifications/-/issues/36// https://docs.rs/unicode-width/latest/unicode_width/#rules-for-determining-width// https://github.com/muesli/reflow/// https://github.com/charmbracelet/x/
// v3.4.1:// - test ansi sequences during truncation// - try poslen -> split for binary states (align, limit)// so far, switching away from string.append was never worth it.// - cprof, eprof, eflamb`e
// v3.5.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?// 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// - split stuff into 2/3 packages?// - move spacer/ellipsis to options?
// -- OPTIONS ------------------------------------------------------------------
/// Options to change the default behaviour of the functions in this library./// If you are unsure what to do here, the defaults should work great!pub opaque type Options { Options( count_ansi_codes: Bool, ambiguous_as_wide: Bool, mode: Mode, tab_width: Int, tab_offset: Int, )}
type Mode { ModeWcwidth Mode2027 Mode2027Ext}
const default_options = Options( count_ansi_codes: False, ambiguous_as_wide: False, mode: ModeWcwidth, tab_width: 8, tab_offset: 0,)
/// Start building up new options.///pub fn new() -> Options { default_options}
/// Measure grapheme clusters instead of individual code points.////// Most terminal emulators do not handle grapheme clusters well and will/// instead show their decomposition. To make sure a given string always fits/// even on those terminals, the functions in this package will copy this/// behaviour as well.////// If you enable this option, the returned numbers will more accurately represent/// the width of a string on a website or in an editor.////// **NOTE:** Grapheme support in terminals is highly experimental and subject/// to ongoing discussion! When working on CLIs or TUIs, you do not want to use/// this option most of the time.////// See also [Grapheme Clusters and Terminal Emulators](https://mitchellh.com/writing/grapheme-clusters-in-terminals)/// for a better explanation on how terminals behave.///pub fn mode_2027(options: Options) -> Options { Options(..options, mode: Mode2027)}
/// Measure grapheme clusters instead of individual code points, and always/// treat grapheme clusters with more than one non-modifier character as wide.////// This is a custom extension to Unicode Core. Many single grapheme clusters/// would currently still render as wide glyphs in many contexts, since font/// shaping would not be able to collapse them into a single narrow "ligature".////// This mode is an additional heuristic that tries to handle more such cases.////// See `mode_2027` for more explanation on the implications.///pub fn mode_2027_ext(options: Options) -> Options { Options(..options, mode: Mode2027Ext)}
/// The default mode and the mode suitable for most terminals.////// Measures individual code points, similar to the libc `wcwidth` function/// that almost all of these terminals use.////// Note that except for `fold_raw`, this library will still always process/// grapheme clusters as a unit.///pub fn mode_wcwidth(options: Options) -> Options { Options(..options, mode: ModeWcwidth)}
/// Some characters are marked by Unicode as "ambiguous", meaning they may/// occupy 1 or 2 cells, depending on the context, current language, selected/// font, surrounding text, and more.////// Unicode recommends treating these characters as narrow by default,/// but you can change this behaviour using this option.///pub fn ambiguous_as_wide(options: Options) -> Options { Options(..options, ambiguous_as_wide: True)}
/// Do not ignore ansi escape sequences, and count them as regular characters.////// You can enable this option as an optimisation if you are sure that your/// string doesn't contain any ansi escape codes.///pub fn count_ansi_codes(options: Options) -> Options { Options(..options, count_ansi_codes: True)}
/// Change the number of columns between tab stops. (Default: 8)////// Whenever a tab character `\t` is encountered, the current column number will/// be rounded up to the next multiple of this number.///pub fn with_tab_width(options: Options, tab_width: Int) -> Options { Options(..options, tab_width:)}
/// Define an _offset_ for tab stop calculations. (Default: 0)////// If the text you print doesn't start at the first column, but is instead/// indented somehow, you can set this option to this number to make sure tab/// stops are correctly calculated.///pub fn at_tab_offset(options: Options, tab_offset: Int) -> Options { Options(..options, tab_offset:)}
// -- MEASURING STRINGS --------------------------------------------------------
/// A custom type making it easy to differentiate the width (columns) and/// height (rows).pub type Size { Size(rows: Int, columns: Int)}
/// Get the size of the terminal, if available.////// Checks the built-in methods for the target, as well as the `LINES` and/// `COLUMNS` environment variables.///pub fn get_terminal_size() -> Result(Size, Nil) { case do_get_terminal_size() { Ok(#(rows, columns)) -> Ok(Size(rows:, columns:)) _ -> Error(Nil) }}
@external(erlang, "string_width_ffi", "get_terminal_size")@external(javascript, "./string_width_ffi.mjs", "get_terminal_size")fn do_get_terminal_size() -> Result(#(Int, Int), Nil)
/// Checks whether the program is running in an interactive terminal (TTY).@external(erlang, "string_width_ffi", "is_tty")@external(javascript, "./string_width_ffi.mjs", "is_tty")pub fn is_tty() -> Bool
/// Get the number of columns required to print a line in a terminal.////// If multiple lines are given, the length of the longest line is returned.////// ### Examples////// ```gleam/// line("äöüè")/// // --> 4////// line("안녕하세요")/// // --> 10////// line("👩👩👦👦")/// // --> 8////// line("\u{1B}[31mhello\u{1B}[39m")/// // --> 5/// ```///pub fn line(str: String) -> Int { let Size(rows: _, columns:) = dimensions_with(str, default_options) columns}
/// Like `line`, but use custom options.////// ### Example////// ```gleam/// let options =/// new()/// |> mode_2027////// line_with("👩👩👦👦", options)/// // --> 2/// ```///pub fn line_with(str: String, options: Options) -> Int { let Size(rows: _, columns:) = dimensions_with(str, options) columns}
/// The required number of rows and columns to display the given text.////// The number of rows is equal to the number of lines, while the number of/// columns represents the maximum line width.////// ### Examples////// ```gleam/// dimensions("안녕하세요")/// // --> Size(rows: 1, columns: 10)////// dimensions("hello,\n안녕하세요")/// // --> Size(rows: 2, columns: 10)/// ```///pub fn dimensions(str: String) -> Size { dimensions_with(str, default_options)}
/// Like `dimensions`, but use custom options.///pub fn dimensions_with(str: String, options: Options) -> Size { let #(str, ranges, range_width) = prepare_measure(options, str)
let fun = fn(state, chr, width) { let #(rows, cols_max, cols_curr) = state
case chr { "\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) } }
let on_range = fn(state, ansi, length) { fun(state, ansi, range_width * length) }
let on_chars = case options.mode { ModeWcwidth -> fn(state, str) { fold_chars_raw(options, str, state, fun) } Mode2027 -> fn(state, str) { fold_chars_2027(options, str, state, fun) } Mode2027Ext -> fn(state, str) { fold_chars_2027_ext(options, str, state, fun) } }
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 let rows = case cols_curr > options.tab_offset { True -> rows + 1 False -> rows }
Size(rows: rows, columns: cols_max)}
/// Get the width of a tab at the given column.fn tab(options: Options, col: Int) -> Int { { col / options.tab_width + 1 } * options.tab_width - col}
// magic hook that allows us to not be stupid with FFI and still get// _most_ of the performance (we are now like 50% slower, or back to old speed).@external(javascript, "./string_width_ffi.mjs", "prepare_measure")fn prepare_measure( options: Options, str: String,) -> #(String, List(#(Int, Int)), Int) { let ansi_ranges = case options.count_ansi_codes { True -> [] False -> ansi.match(str) } #(str, ansi_ranges, 0)}
// -- LAYOUT UTILS -------------------------------------------------------------
/// Limit the dimensions of a string, either by wrapping on white space/// characters or soft hyphens, or by truncating the last line and appending an/// ellipsis.////// The lines of the resulting string are left aligned and are at most `columns`/// wide. If a single word is longer than the maximum allowed line width, the/// word is broken into 2 pieces at the grapheme level. If the string gets/// truncated, this function will keep collecting ANSI sequences to make sure/// all formatting is preserved.////// A commonly used ellipsis character is `"…"`, also known as/// U+2026 HORIZONTAL ELLIPSIS.////// ### Examples////// ```gleam/// limit("Hello World", Size(rows: 1, columns: 10), ellipsis: "...")/// // --> "Hello W..."////// limit("Hello World", Size(rows: 2, columns: 10), ellipsis: "...")/// // --> "Hello\nWorld"/// ```///pub fn limit( str: String, to max_size: Size, ellipsis ellipsis: String,) -> String { limit_with(str, max_size, default_options, ellipsis)}
/// Like `limit`, but also customise the options used for measuring.///pub fn limit_with( str: String, to max_size: Size, using options: Options, ellipsis ellipsis: String,) -> String { soft_wrap_with(str, max_size, options, ellipsis, fn(_) { "" })}
/// Limit the dimensions of a string, either by wrapping on white space/// characters or soft hyphens while using a custom indentation function, or by/// truncating the last line and appending an ellipsis.////// This function gives a little bit more control over what happens on soft/// line breaks than `limit` and allows you to inspect the line printed so far/// to figure out the indentation for all soft-wrapped lines. The `to_indent`/// callback is called with the line so far, and should return an indentation/// string (usually just the proper amount of spaces) that is prepended to all/// the following soft-wrapped lines.////// Behaves like `limit` otherwise.////// ### Examples////// ```gleam/// fn to_indent(str) {/// case str {/// // if it is a list, indent with 2 spaces/// "- " <> _ -> " "/// // if it is a blockquote, prepend another blockquote marker/// "> " <> _ -> "> "/// // else, don't indent (like limit)/// _ -> ""/// }/// }////// // Girly Girl Productions - 10 drunk cigarettes/// let lyrics = "/// > Getting girls rich, yeah, that's a part of my plan./// > And I could name 10 things us girls need, before we ever need a man:////// - One new vape, two lines of coke/// - Three drinks from the bar, four more lines of coke/// - Five Guys fries, six hits from my blunt/// - Seven more lines of coke, eight pairs of shoes/// - Nine BB belts, and 10 drunk cigarettes./// "////// lyrics/// |> soft_wrap(to: Size(rows: 24, columns: 34), ellipsis: "...", to_indent:)/// |> io.println/// ```////// **Output:**////// ```txt/// > Getting girls rich, yeah, that's/// > a part of my plan./// > And I could name 10 things us/// > girls need, before we ever need/// > a man:////// - One new vape, two lines of coke/// - Three drinks from the bar, four/// more lines of coke/// - Five Guys fries, six hits from/// my blunt/// - Seven more lines of coke, eight/// pairs of shoes/// - Nine BB belts, and 10 drunk/// cigarettes./// ```////// **Note:** This is not meant to allow for arbitrary text formatting, but as a/// quick way to indicate soft-wrapping in the printed text. As shown in the/// example, it _can_ be used to do some very light formatting, but you should/// almost always parse the string first, and print it in a way that makes sense/// for your text format, for example using the awesome/// [glam](https://hexdocs.pm/glam/) package!///pub fn soft_wrap( str: String, to max_size: Size, ellipsis ellipsis: String, to_indent to_indent: fn(String) -> String,) -> String { soft_wrap_with(str, max_size, default_options, ellipsis, to_indent)}
// import gleam/io
/// Like `soft_wrap`, but using custom options to measure the strings and indents.///pub fn soft_wrap_with( str: String, to max_size: Size, using options: Options, ellipsis ellipsis: String, to_indent to_indent: fn(String) -> String,) -> String { let ellipsis_width = spacer_width(options, ellipsis) let max_row = max_size.rows
let push = fn(state, piece, width) { // io.debug(#("push", state, piece, width)) limit_state_push( options, max_size, ellipsis, ellipsis_width, to_indent, state, piece, width, ) }
let commit = fn(state, piece, width, is_final) { // io.debug(#("commit", state, piece, width)) limit_state_commit( options, max_size, ellipsis, ellipsis_width, to_indent, state, piece, width, is_final, ) }
let initial = LimitState( str: "", line: "", row: 0, col: 0, spaces: "", spaces_width: 0, non_spaces: "", non_spaces_width: 0, overflow: "", overflow_width: 0, indent: IndentUnknown, shy: False, )
let state = { use state, piece, width <- fold_raw(str, options, initial)
case state.row >= max_row { // reached the end, but we still want to collect ansi sequences True -> case is_ansi_component(piece, options) { True -> LimitState( line: state.line <> piece, str: state.str, row: state.row, col: state.col, spaces: state.spaces, spaces_width: state.spaces_width, non_spaces: state.non_spaces, non_spaces_width: state.non_spaces_width, overflow: state.overflow, overflow_width: state.overflow_width, indent: state.indent, shy: state.shy, ) False -> state }
False -> case piece { // Line break that we recognise as such "\n" -> { let state = commit(state, "", 0, False) // io.debug(#("newline", state)) LimitState( str: state.str <> state.line <> "\n", line: "", row: state.row + 1, col: 0, shy: False, indent: IndentUnknown, spaces: state.spaces, spaces_width: state.spaces_width, non_spaces: state.non_spaces, non_spaces_width: state.non_spaces_width, overflow: state.overflow, overflow_width: state.overflow_width, ) }
"\t" -> { // tab has a different width depending on whether or not we will wrap let curr_col = limit_state_col(state) let width = case curr_col <= max_size.columns { True -> tab(options, options.tab_offset + curr_col) False -> { let wrapped_col = state.non_spaces_width + state.overflow_width tab(options, options.tab_offset + wrapped_col) } }
commit(state, piece, width, False) }
// White_Space=Y, excluding control characters and non-breaking spaces " " | "\u{1680}" | "\u{2000}" | "\u{2001}" | "\u{2002}" | "\u{2003}" | "\u{2004}" | "\u{2005}" | "\u{2006}" | "\u{2008}" | "\u{2009}" | "\u{200a}" | "\u{205f}" | "\u{3000}" -> commit(state, piece, width, False)
// soft hyphen. we handle the details in commit "\u{ad}" -> commit(state, piece, width, False)
// not a space or a line break _ -> push(state, piece, width) } } }
// commit the last word, if any let state = commit(state, "", 0, True) // io.debug(#("final", state)) state.str <> state.line}
type Indent { IndentUnknown Indent(str: String, width: Int)}
/// The loop state during text wrapping.////// The general idea is to collect pieces of the source string into a "staging"/// area (spaces, non_spaces, overflow) until we know for sure what to do w.r.t./// line breeaks, at which point we "commit" the staging strings into line and/// str respectivily.////// commits are usually taken after we encounter a space character (as our/// approximation of a word boundary, since we do not implement unicode word/// segmentation), or after a shy or newline, in emergency situations where we/// have to break a word apart.type LimitState { LimitState( /// the final committed buffer str: String, /// line is committed, but kept around separately if we need to compute indent. line: String, /// the current cursor position of the committed line (and str) row: Int, col: Int, /// spaces in between non_spaces. collected separetely because we drop them /// if we soft_wrap, and replace them by indent instead. spaces: String, spaces_width: Int, /// non_spaces are characters that safely fit the current line together with /// spaces without exceeding max_size.columns. non_spaces: String, non_spaces_width: Int, /// non-space characters that are too long for the current line. /// we collect them at first normally before we know if we want an emergency /// line break or a normal soft-wrapping line break. /// /// we need to emergency break once non_spaces+overflow > max_columns-indent /// at this point, we also need to reflow (truncate) the word to find the /// emergency break boundary. /// /// if we are at the last line, we start overflowing ellipsis_width columns /// earlier, to leave space for an ellipsis, if we have to push one. overflow: String, overflow_width: Int, /// contains indent information if we already had to soft-wrap indent: Indent, /// true if the previous commit was due to a shy, and we should insert a /// hyphen if we soft-wrap at this point. shy: Bool, )}
fn limit_state_col(state: LimitState) { state.col + state.spaces_width + state.non_spaces_width + state.overflow_width}
fn limit_emergency_break_reflow( str: String, at max_col: Int, using options: Options,) { let initial = #("", "", 0) use #(before, after, width), piece <- fold_with(str, options, initial) case piece.column + piece.width > max_col { True -> #(before, after <> piece.piece, width + piece.width) False -> #(before <> piece.piece, after, width) }}
fn limit_emergency_truncate( str: String, at max_col: Int, using options: Options,) { use #(truncated, width), piece <- fold_with(str, options, #("", 0)) case is_ansi_component(piece.piece, options) || piece.column + piece.width <= max_col { True -> #(truncated <> piece.piece, width + piece.width) False -> #(truncated, width) }}
fn limit_state_indent( get_indent: fn(String) -> String, options: Options, state: LimitState,) { case state.indent { IndentUnknown -> { let indent = get_indent(state.line) let indent_width = line_with(indent, options) #(indent, indent_width) } Indent(indent, indent_width) -> #(indent, indent_width) }}
/// push a word-character into LimitStatefn limit_state_push( options: Options, max_size: Size, ellipsis: String, ellipsis_width: Int, get_indent: fn(String) -> String, state: LimitState, piece: String, width: Int,) { // we need to check for 2 boundaries: // 1. the non_spaces vs overlfow boundary - does this character fit normally? // 2. the emergency break boundary - do we have to commit? // // emergency breaking gets more complicated because we also need to check if we // can actually line break (or need to truncate), and because we need to // potentially reflow the overflow because of a indent we don't know about yet.
// the column we would end up with if we committed the word onto the current // line normally, without soft-wrapping or emergency breaking. let line_col = limit_state_col(state) + width
// the column to check against to see if we need to emergency break. // keep in mind that this might bigger (by indent) if we don't know the indent yet. let emergency_break_col = case state.indent { IndentUnknown -> state.non_spaces_width + state.overflow_width + width Indent(_, indent_width) -> indent_width + state.non_spaces_width + state.overflow_width + width }
// the column at which we would start pushing into overflow instead of non_spaces. // if emergency_break_col overflows this, we need to emergency break instead. let lines_remaining = max_size.rows - state.row - 1 let overflow_col = case lines_remaining { 0 -> max_size.columns - ellipsis_width _ -> max_size.columns }
// we can use the fact that line_col >= emergency_break_col // we cannot take emergency line breaks anymore on the last line, so we just collect into overflow. case line_col <= overflow_col, emergency_break_col <= overflow_col || lines_remaining == 0 { True, _ -> { // this character fits normally and we can collect it into non_spaces right now. LimitState( non_spaces: state.non_spaces <> piece, non_spaces_width: state.non_spaces_width + width, // str: state.str, line: state.line, row: state.row, col: state.col, spaces: state.spaces, spaces_width: state.spaces_width, overflow: state.overflow, overflow_width: state.overflow_width, indent: state.indent, shy: state.shy, ) } False, True -> { // this word does not fit the current line. // at this point we know that we will either soft-wrap or emergency break, // so we can pre-compute the indent. // This is important such that our emergency_break_col is correct. let indent = get_indent(state.line <> state.spaces <> state.non_spaces) let indent_width = line_with(indent, options)
case state.non_spaces_width == 0 && lines_remaining > 0 { True -> { // this is the first non-space character that we push, but we would // already overflow! this is annoying, so let's fix it. LimitState( str: state.str <> state.line <> state.non_spaces <> "\n", line: indent, col: indent_width, row: state.row + 1, non_spaces: state.overflow <> piece, non_spaces_width: state.overflow_width + width, indent: Indent(indent, indent_width), overflow: "", overflow_width: 0, spaces: "", spaces_width: 0, shy: False, ) }
False -> LimitState( overflow: state.overflow <> piece, overflow_width: state.overflow_width + width, indent: Indent(indent, indent_width), // str: state.str, line: state.line, row: state.row, col: state.col, non_spaces: state.non_spaces, non_spaces_width: state.non_spaces_width, spaces: state.spaces, spaces_width: state.spaces_width, shy: state.shy, ) } } False, False -> { // in an emergency break, we have at least 2 lines we want to push // - if the last commit was at a shy, we always want to take it. // - we at least have 1 full line that we need to reflow to figure out the breakpoint // - push the remaining characters into the new staging area.
let #(indent, indent_width) = limit_state_indent(get_indent, options, state) // we can only push a shy if we are not yet at the last line. case lines_remaining, state.shy { 1, True -> { // we want to take this shy break, but we have only 1 line left after that let #(truncated, _) = limit_emergency_truncate( state.non_spaces <> state.overflow <> piece, max_size.columns - ellipsis_width - indent_width, options, )
LimitState( str: state.str <> state.line <> "-\n" <> indent <> truncated <> ellipsis, col: max_size.columns, row: max_size.rows, indent: Indent(indent, indent_width), // line: "", shy: False, spaces: "", spaces_width: 0, non_spaces: "", non_spaces_width: 0, overflow: "", overflow_width: 0, ) }
_, True -> { // we want to take this shy break, and have plenty of space. let #(before, after, after_width) = limit_emergency_break_reflow( state.non_spaces <> state.overflow <> piece, max_size.columns - indent_width, options, )
LimitState( str: state.str <> state.line <> "-\n" <> indent <> before <> "\n", line: indent, non_spaces: after, non_spaces_width: after_width, col: indent_width, row: state.row + 2, indent: Indent(indent, indent_width), shy: False, spaces: "", spaces_width: 0, overflow: "", overflow_width: 0, ) }
_, False -> { let #(truncated, overflow, overflow_width) = limit_emergency_break_reflow( state.non_spaces <> state.overflow <> piece, state.non_spaces_width, options, )
LimitState( str: state.str <> state.line <> state.spaces <> truncated <> "\n", line: indent, non_spaces: overflow, non_spaces_width: overflow_width, row: state.row + 1, col: indent_width, indent: Indent(indent, indent_width), spaces: "", spaces_width: 0, overflow: "", overflow_width: 0, shy: False, ) } } } }}
/// Push a non-word character into LimitState, commiting the currently accumulated word.fn limit_state_commit( options: Options, max_size: Size, ellipsis: String, ellipsis_width: Int, get_indent: fn(String) -> String, state: LimitState, piece: String, width: Int, is_final: Bool,) { // we encode a few states, but we do it this way such that spread works... // - if state.row == max_size.rows, we have reached the end and should do nothing // - if we have oveflow, we are at the last line and stuff doesn't fit. // - if we have non_spaces or overflow, we are collecting word characters. // - if we do not, we are collecting spaces _before_ a word. // - on the first space we collect, if we notice that we have word characters, // we try to push this word into the accumulator. // if it doesn't fit on the last line, we have overflow, and can append the ellipsis. // afterwards, we set row = max_sie.rows to indicate we are finished. let new_col = limit_state_col(state)
// make sure we definitely have room for the ellipsis on the last line let max_row = max_size.rows let is_last_line = state.row + 1 >= max_row
// pushing a shy is a special case, we want to push nothing for now, // but record the fact that we got a shy as the last thing we pushed. let shy = piece == "\u{ad}" let piece = case shy { True -> "" False -> piece }
case state.non_spaces_width == 0, state.overflow_width == 0 { // we do not have non-spaces, so we are still collecting spaces. True, True -> case state.row == 0 && state.col == 0 { // skip spaces at the start, since we skip them at the end. True -> state False -> LimitState( spaces: state.spaces <> piece, spaces_width: state.spaces_width + width, // str: state.str, line: state.line, row: state.row, col: state.col, non_spaces: state.non_spaces, non_spaces_width: state.non_spaces_width, overflow: state.overflow, overflow_width: state.overflow_width, indent: state.indent, shy: state.shy, ) }
// we have non-spaces, so we want to push this word into the accumulator // and then start again collecting spaces. // // we already know that non-spaces will fit, otherwise we would have pushed // into overflow. // would it fit into the line just normally? // it fits normally if there is no overflow and we break not because of a // shy, or the hyphen would also fit. False, True -> { // it fits normally, nothing special required! let line = state.line <> state.spaces <> state.non_spaces <> state.overflow LimitState( str: state.str, line: line, row: state.row, col: new_col, spaces: piece, spaces_width: width, shy: shy && new_col + 1 < max_size.columns, indent: state.indent, non_spaces: "", non_spaces_width: 0, overflow: "", overflow_width: 0, ) }
_, False -> { // it doesn't; if we are at the last line, truncate. else, wrap.
case is_last_line, is_final, state.overflow_width > ellipsis_width { True, True, False -> { // final push where the overflow is not longer than the ellipsis LimitState( str: state.str <> state.line <> state.spaces <> state.non_spaces <> state.overflow, line: "", col: state.col + state.spaces_width + state.non_spaces_width + state.overflow_width, row: state.row, spaces: piece, spaces_width: width, non_spaces: "", non_spaces_width: 0, overflow: "", overflow_width: 0, indent: state.indent, shy: False, ) }
True, _, _ -> { let #(truncated, truncated_width) = limit_emergency_truncate( state.non_spaces <> state.overflow <> piece, state.non_spaces_width, options, )
// TODO: check for truncated_width instead. let str = case truncated_width { 0 -> state.str <> state.line <> truncated <> ellipsis _ -> state.str <> state.line <> state.spaces <> truncated <> ellipsis }
LimitState( str:, line: "", row: state.row + 1, col: max_size.columns, spaces: "", spaces_width: 0, non_spaces: "", non_spaces_width: 0, overflow: "", overflow_width: 0, indent: IndentUnknown, shy: False, ) }
False, _, _ -> { let #(indent, indent_width) = limit_state_indent(get_indent, options, state)
let str = case state.shy { True -> state.str <> state.line <> "-\n" False -> state.str <> state.line <> "\n" }
LimitState( str: str, line: indent <> state.non_spaces <> state.overflow, row: state.row + 1, col: indent_width + state.non_spaces_width + state.overflow_width, spaces: piece, spaces_width: width, non_spaces: "", non_spaces_width: 0, overflow: "", overflow_width: 0, indent: Indent(indent, indent_width), shy: shy, ) } } } }}
/// Replace all tab characters found in the string with the amount of spaces/// that this tab would have had otherwise.////// This makes it safe to prepend to a line, without changing the spacing produced/// by tabs anymore.////// ### Examples////// ```gleam/// tabs_to_spaces("Hello\tWorld")/// // --> "Hello World" // 3 spaces/// ```///pub fn tabs_to_spaces(str: String) -> String { tabs_to_spaces_with(str, default_options)}
/// Like `tabs_to_spaces`, but customise the opttions as well.////// **NB:** You can use `at_tab_offset` and `with_tab_width` to change the measure of/// tab characters inside the string!////// ```gleam/// let options = new() |> with_tab_width(4)/// tabs_to_spaces_with("hi\tcutie~", options)/// // --> "hi cutie~"/// ```///pub fn tabs_to_spaces_with(str: String, using options: Options) -> String { use acc, piece <- fold_raw_pieces(str, options, from: "") case piece.piece { "\t" -> acc <> string.repeat(" ", piece.width) _ -> acc <> piece.piece }}
/// Control the text or box alignment on the horizontal axis.pub type Alignment { Left Center Right}
/// Control the box alignment on the vertical axis.pub type Placement { Top Middle Bottom}
/// Position the string area inside a bigger box without changing text alignment./// The box will be filled with the space character.////// **Tip:** If you only want to align a string on one axis, you can provide/// `0` for the orthogonal one! Extra characters and lines will be kept.////// If the space strings' width does not evenly divide the missing amount of/// columns, the extra spacer will overflow the max width.////// ### Examples////// ```gleam/// position("X", in: Size(3, 3), align: Center, place: Middle, with: "o")/// // --> "ooo\noXo\nooo"////// // text still left-aligned, but moved to the right by 1 space/// position("Trans\nrights\nare\nhuman\nrights", Size(0, 7), Right, Top, " ")/// // --> " Trans \n rights\n are \n human \n rights"/// ```///pub fn position( str: String, in bounding_box: Size, align alignment: Alignment, place placement: Placement, with space: String,) -> String { position_with(str, bounding_box, alignment, placement, default_options, space)}
/// Like `position`, but use custom options to measure each line.///pub fn position_with( str: String, in bounding_box: Size, horizontal alignment: Alignment, vertical placement: Placement, using options: Options, with space: String,) -> String { let size = dimensions_with(str, options)
let left = case alignment { Left -> 0 Right -> int.min(size.columns - bounding_box.columns, 0) Center -> int.min({ size.columns - bounding_box.columns } / 2, 0) }
let top = case placement { Top -> 0 Bottom -> int.min(size.rows - bounding_box.rows, 0) Middle -> int.min({ size.rows - bounding_box.rows } / 2, 0) }
let width = int.max(size.columns, bounding_box.columns) let height = int.max(size.rows, bounding_box.rows)
box(str, top:, left:, width:, height:, using: options, with: space)}
/// Align each line horizontally, using a spacer character or string as a filler./// Each line of the return value will be at least `max_width` wide.////// If a line is already longer than the max width, it will not be changed./// If the space strings width does not evenly divide the missing amount of/// columns, the extra spacer will overflow the max width.////// ### Examples////// ```gleam/// align("", to: 5, align: Left, with: "x")/// // --> "xxxxx"////// align("hello", to: 10, align: Left, with: " ")/// // --> "hello "////// align("12345", to: 8, align: Right, with: "0")/// // --> "00012345"////// align(" Welcome ", to: 20, align: Center, with: "==")/// // --> "====== Welcome ======" // (len = 21)////// align("Trans\nrights\nare\nhuman\nrights", to: 7, align: Right, with: " ")/// // --> " Trans\n rights\n are\n human\n rights"/// ```///pub fn align( str: String, to max_width: Int, align alignment: Alignment, with space: String,) -> String { align_with(str, max_width, alignment, default_options, space)}
/// Like `align`, bu use custom options for measure.///pub fn align_with( str: String, to max_width: Int, align alignment: Alignment, using options: Options, with space: String,) -> String { let space_width = spacer_width(options, space) let str_size = dimensions_with(str, options)
// in contrast to other view functions, we want to treat empty strings // as empty lines, not empty blocks here. hence int.max(1, _) let bottom = int.max(1, str_size.rows) let right = int.max(str_size.columns, max_width)
use line, line_width <- view(str, 0, 0, options, bottom:, right:) let missing = div_up(max_width - line_width, space_width) case alignment { Left -> line <> string.repeat(space, missing) Right -> string.repeat(space, missing) <> line Center -> { let left = missing / 2 let right = missing - left string.repeat(space, left) <> line <> string.repeat(space, right) } }}
/// 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 options = default_options let size = dimensions_with(str, options) // padding is like negative scrolling - // if we want more spaces, we need to move the viewport in the opposite direction. let width = size.columns + left + right let height = size.rows + top + bottom box(str, options, top: -top, left: -left, width:, height:, with: space)}
/// 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 size = dimensions_with(str, options) // padding is like negative scrolling - // if we want more spaces, we need to move the viewport in the opposite direction. let width = size.columns + left + right let height = size.rows + top + bottom box(str, options, top: -top, left: -left, width:, height:, with: space)}
/// Cut out a `viewort` at the given origin `#(top, left)` from a string. This/// is similar to _"scrolling"_ a web page.////// You can scroll past the filled area of the string in any direction./// Empty space is filled using the `space` string. If the space string does not/// evenly fill the empty area, some lines may be longer than the given viewport.////// All ANSI sequences in the entire string will be kept, even if they are/// outside of the visible viewport.////// ### Example////// ```gleam/// scroll("wibble", top: 0, left: 2, view: Size(1, 2), with: " ")/// // --> "bb"////// "Hello\nHow are you today?\nDid you know:\nLucy says trans rights!"/// |> scroll(top: 2, left: 10, view: Size(3, 5), with: "-")/// // --> "ow:--\ntrans\n-----"/// ```///pub fn scroll( str: String, top top: Int, left left: Int, view viewport_size: Size, with space: String,) -> String { let options = default_options let Size(rows: height, columns: width) = viewport_size box(str, options, top:, left:, width:, height:, with: space)}
/// Like `scroll`, but provide custom options for measuring.///pub fn scroll_with( str: String, top top: Int, left left: Int, view viewport_size: Size, using options: Options, with space: String,) -> String { let Size(rows: height, columns: width) = viewport_size box(str, top:, left:, width:, height:, using: options, with: space)}
/// 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, 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) { // NOTE: we can't get negative widths here, since we won't scroll offscreen 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, 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, top top: Int, left left: Int, height height: Int, width width: Int, using options: Options, with space: String,) { let space_width = spacer_width(options, space)
let missing_left = div_up(int.max(0, -left), space_width) let padding_left = string.repeat(space, missing_left)
let bottom = top + height let right = left + width
use line, line_width <- view(str, top, left, bottom, right, options) let missing_right = div_up(width - missing_left - line_width, space_width) let padding_right = string.repeat(space, missing_right)
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, top top: Int, left left: Int, bottom bottom: Int, right right: Int, using options: Options, align align: fn(String, Int) -> String,) { let push = fn(buf, line, line_width) { case line_width >= 0 { True -> buf <> "\n" <> align(line, line_width) False -> buf <> line } }
let result = view_fold(str, top, left, bottom, right, options, "", push)
case result { "\n" <> result -> result _ -> 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, 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 total_height = int.max(0, bottom - top) let line_width_offset = int.max(0, left) 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 acc = push(state.acc, state.line, state.col - line_width_offset) ViewState(acc:, line: "", row: state.row + 1, col: 0) } } }
// vertical padding top let acc = repeat(int.min(-top, total_height), initial, push(_, "", 0)) let 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 }
case piece { "\n" -> push_line(state) _ -> case state.row < top || state.row >= bottom || state.col < left || state.col >= right { True -> case is_ansi_component(piece, options) { True -> ViewState( acc: state.acc, line: state.line <> piece, row: state.row, col: state.col, )
False -> ViewState( acc: state.acc, line: state.line, row: state.row, col: state.col + width, ) } False -> ViewState( acc: state.acc, line: state.line <> piece, row: state.row, col: state.col + width, ) } } }
// if we are inside the viewable rows, push a normal line // otherwise, push the remaining ansi sequences let acc = case state.row >= top && state.row < bottom { True -> push(state.acc, state.line, state.col - line_width_offset) False -> push(state.acc, state.line, -1) }
// vertical padding bottom // if we have visible columns, we have already added a line with the previous push. let missing_bottom = case state.col >= line_width_offset { True -> bottom - state.row - 1 False -> bottom - state.row }
repeat(int.min(missing_bottom, total_height), acc, push(_, "", 0))}
/// Integer division, rounding upfn 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 { "" -> 0 " " -> 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)/// (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/// resulting string, without affecting the styling anymore.////// It does so by collecting all SGR sequences since the last reset, and/// re-applying them on each line break.////// **Warning:** This function assumes it is safe to emit reset commands,/// and that no ambient styling is in effect (that is, the terminal would be in/// its default state when printing the resulting string!)////// ### Examples////// ```gleam/// // "\u{1b}[31m": red text color/// // "\u{1b}[m": reset/normal mode/// inline_styles("\u{1b}[31mhello\nworld")/// // --> "\u{1b}[31mhello" <> "\u{1b}[m\n" <> "\u{1b}[31mworld" <> "\u{1b}[m"/// ```///pub fn inline_styles(str: String) -> String { let ansi_ranges = ansi.match(str)
let on_range = fn(state, ansi, _) { let #(buf, sgr) = state case ansi { "\u{1b}[0m" | "\u{1b}[m" -> #(buf <> ansi, "") _ -> case string.ends_with(ansi, "m") { True -> #(buf <> ansi, sgr <> ansi) False -> #(buf <> ansi, sgr) } } }
let on_chars = fn(state, chars) { case state { #(buf, "") -> #(buf <> chars, "") #(buf, sgr) -> { let #(rest, _, buf) = { use #(ptr, pos, buf), byte <- fold_bytes(chars, #(chars, 0, buf)) case byte { 0xA -> { let assert #(line, "\n" <> ptr) = unsafe_split(ptr, pos) #(ptr, 0, buf <> line <> "\u{1b}[m\n" <> sgr) } _ -> #(ptr, pos + 1, buf) } }
#(buf <> rest, sgr) } } }
let state = fold_parts(str, 0, ansi_ranges, #("", ""), on_chars, on_range)
case state { #(buf, "") -> buf #(buf, _sgr) -> buf <> "\u{1b}[m" }}
/// Strip _all_ ansi sequences from a string, using the same matching algorithm/// that this library uses internally.////// This makes it safe to enable `count_ansi_codes`, or print a string without/// having to worry that it might mess with the terminal and/or colors.////// Unexpected characters inside of escape sequences are ignored (swallowed).////// ### Examples////// ```gleam/// strip_ansi("\u{1b}[0;33;49;3;9;4mhi~\u{1b}[0m")/// // --> "hi~"////// strip_ansi("check out \u{1b}]8;;https://gleam.run\u{7}Gleam!\u{1b}]8;;\u{7}")/// // --> "check out Gleam!"/// ```///pub fn strip_ansi(str: String) -> String { ansi.strip(str)}
/// Returns true if a given component string recieved in `fold` is an ANSI/// escape sequence.///pub fn is_ansi_component(str: String, options: Options) -> Bool { case options.count_ansi_codes { True -> False False -> case str { "\u{1b}" <> _ | "\u{9b}" <> _ | "\u{9d}" <> _ -> True _ -> False } }}
// -- FOLD ---------------------------------------------------------------------
/// The measured pieces of a string, passed to you by `fold` and `fold_with`pub type Piece { Piece(piece: String, row: Int, column: Int, width: Int)}
/// A higher-level `fold` that keeps track of the position inside of the string.////// Handles tabs and newlines, and always passes full grapheme clusters,/// regardless of options. Concatenating the graphemes produces the original string.////// 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)]/// ```///pub fn fold( over str: String, from state: state, with fun: fn(state, Piece) -> state,) -> state { fold_with(str, default_options, state, fun)}
/// 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.////// Intended to be used as a basis for custom layout algorithms.///pub fn fold_with( over str: String, using options: Options, from state: state, with fun: fn(state, Piece) -> state,) -> state { let #(state, fun) = piece_reducer(state, options, fun)
let on_chars = case options.mode { ModeWcwidth -> fn(state, str) { fold_chars_wcwidth(options, str, state, fun) } Mode2027 -> fn(state, str) { fold_chars_2027(options, str, state, fun) } Mode2027Ext -> fn(state, str) { fold_chars_2027_ext(options, str, state, fun) } }
let ansi_ranges = case options.count_ansi_codes { True -> [] False -> ansi.match(str) }
let on_range = fn(state, ansi, _) { fun(state, ansi, 0) }
let #(state, _, _) = fold_parts(str, 0, ansi_ranges, state, on_chars, on_range)
state}
/// A low-level fold that iterate over the measured components of a string./// Components are either graphemes or codepoints depending on the `mode` option,/// or other undivisible sequences, like ANSI escape codes.////// This function does not by itself keep track of any additional state, and/// doesn't for example handle newlines or tabs like `fold` would./// If you know that your algorithm is safe and won't break up graphemes (or/// maybe this is acceptable), you can use this fold variant instead as a/// performance optimisation.////// Concatenating all components is guaranteed to produce the original string.////// ### Examples////// ```gleam/// // A slower string_width.line implementation that doesn't handle tabs/// fold_raw("hello", new(), from: 0, with: fn(so_far, _chr, width) { width + so_far })/// // --> 5/// ```////// ```gleam/// // truncate a string after 50 characters, but keep all ansi sequences./// let options = new()/// use #(total, acc), chr, width <- fold_raw(input, options, from: #(0, ""))/// case total >= 50 {/// True -> case is_ansi_component(chr, options) {/// True -> #(total, acc <> chr)/// False -> #(total, acc)/// }/// False -> #(total + width, acc <> chr)/// }/// ```///pub fn fold_raw( over string: String, using options: Options, from state: state, with fun: fn(state, String, Int) -> state,) -> state { let ansi_ranges = case options.count_ansi_codes { True -> [] False -> ansi.match(string) }
let on_chars = case options.mode { ModeWcwidth -> fn(state, str) { fold_chars_raw(options, str, state, fun) } Mode2027 -> fn(state, str) { fold_chars_2027(options, str, state, fun) } Mode2027Ext -> fn(state, str) { fold_chars_2027_ext(options, str, state, fun) } }
let on_range = fn(state, ansi, _) { fun(state, ansi, 0) }
fold_parts(string, 0, ansi_ranges, state, on_chars, on_range)}
/// 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)]/// ```///pub fn fold_raw_pieces( over string: String, using options: Options, from state: state, with fun: fn(state, Piece) -> state,) -> state { let ansi_ranges = case options.count_ansi_codes { True -> [] False -> ansi.match(string) }
let #(state, fun) = piece_reducer(state, options, fun)
let on_chars = case options.mode { ModeWcwidth -> fn(state, str) { fold_chars_raw(options, str, state, fun) } Mode2027 -> fn(state, str) { fold_chars_2027(options, str, state, fun) } Mode2027Ext -> fn(state, str) { fold_chars_2027_ext(options, str, state, fun) } }
let on_range = fn(state, ansi, _) { fun(state, ansi, 0) }
let #(state, _, _) = fold_parts(string, 0, ansi_ranges, state, on_chars, on_range)
state}
fn piece_reducer( state: state, options: Options, fun: fn(state, Piece) -> state,) -> #( #(state, Int, Int), fn(#(state, Int, Int), String, Int) -> #(state, Int, Int),) { let initial = #(state, 0, options.tab_offset) let fun = fn(state, piece, width) { let #(state, row, column) = state let width = case piece { "\t" -> tab(options, column) _ -> width }
let state = fun(state, Piece(piece:, row:, column:, width:))
case piece { "\n" -> #(state, row + 1, options.tab_offset) _ -> #(state, row, column + width) } }
#(initial, fun)}
fn fold_parts( over string: String, at offset: Int, ranges ranges: List(#(Int, Int)), from state: state, on_chars on_chars: fn(state, String) -> state, on_range on_range: fn(state, String, Int) -> state,) -> state { case ranges { [#(start, length), ..ranges] if start == offset -> { let #(range_slice, string) = unsafe_split(string, length)
let state = on_range(state, range_slice, length)
let offset = start + length fold_parts(string, offset, ranges, state, on_chars, on_range) } [#(start, length), ..ranges] -> { let #(before, at_range) = unsafe_split(string, start - offset) let #(range_slice, string) = unsafe_split(at_range, length)
let state = state |> on_chars(before) |> on_range(range_slice, length)
let offset = start + length fold_parts(string, offset, ranges, state, on_chars, on_range) } [] -> case string { "" -> state _ -> on_chars(state, string) } }}
fn fold_chars_raw( options: Options, string: String, state: state, fun: fn(state, String, Int) -> state,) -> state { use state, cp <- fold_codepoints(string, state) fun(state, utf_codepoint_to_string(cp), wcwidth(options, cp))}
fn fold_chars_wcwidth( options: Options, string: String, state: state, fun: fn(state, String, Int) -> state,) -> state { use state, grapheme <- fold_graphemes(string, state) let width = { use acc, cp <- fold_codepoints(grapheme, 0) acc + wcwidth(options, cp) } fun(state, grapheme, width)}
fn fold_chars_2027( options: Options, string: String, state: state, fun: fn(state, String, Int) -> state,) -> state { use state, grapheme <- fold_graphemes(string, state) let width = measure_mode_2027(options, grapheme) fun(state, grapheme, width)}
fn fold_chars_2027_ext( options: Options, string: String, state: state, fun: fn(state, String, Int) -> state,) -> state { use state, grapheme <- fold_graphemes(string, state) let width = measure_mode_2027_ext(options, grapheme) fun(state, grapheme, width)}
fn measure_mode_2027(options: Options, string: String) -> Int { use max, cp <- fold_codepoints(string, 0) case cp { // unicode core: VS16 forces a cluster to be wide 0xfe0f if max > 0 -> 2 _ -> int.max(max, wcwidth(options, cp)) }}
fn measure_mode_2027_ext(options: Options, string: String) -> Int { // The Unicode Core spec (https://github.com/contour-terminal/terminal-unicode-core) // proposing Mode 2027 says the following: // // > Therefore, extending a grapheme cluster with consecutively added codepoints // > will not move the cursor except for variation selector 16 (VS16) that may // > have caused the width of the grapheme cluster to change to wide (2 grid cells). // // I do not agree. // // Many grapheme clusters include multiple letters with a width, which cannot // be printed and combined into a single cell. (examples: च्छे षि র্য バ) // // Therefore, in addition to the rules in this proposal, having 2 or more // non-modifier codepoints with width in a cluster forces it to wide. // // This more accurately represents what font shaping would produce in // non-terminal applications, and not a single terminal in my tests implements // the aformentioned spec correctly (in my understanding) anyways. use max, cp <- fold_codepoints(string, 0) case max == 0 { True -> wcwidth(options, cp) False -> case cp { // unicode core: VS16 forces a cluster to be wide 0xfe0f -> 2 _ -> { case wcwidth(options, cp) { 0 -> max // extension: 2 or more codepoints with width force a cluster to be wide width -> case is_spacing_mark(cp) { True -> int.max(max, width) False -> 2 } } } } }}
fn wcwidth(options: Options, cp: Int) -> Int { // see: https://git.musl-libc.org/cgit/musl/tree/src/ctype/wcwidth.c case cp { // cheap optimization for ascii _ if cp <= 0x1f -> 0 _ if cp <= 0x7e -> 1 _ -> case is_ignorable(cp) { True -> 0 False -> case is_wide(cp) || { options.ambiguous_as_wide && is_ambiguous(cp) } { True -> 2 False -> 1 } } }}
// -- TABLE ACCESSORS ----------------------------------------------------------
// the data for these functions is generated by the generate.gleam script.// the general idea is heavily inspired by the tables used in musl libc.
fn is_ignorable(cp: Int) -> Bool { case cp <= 0x1ffff { True -> table_lookup(tables.ignorable, cp) False -> cp >= 0xe0000 && cp <= 0xe0fff }}
fn is_ambiguous(cp: Int) -> Bool { case cp <= 0xffff { True -> table_lookup(tables.ambiguous, cp) False -> { { cp >= 0x1f100 && cp <= 0x1f10a } || { cp >= 0x1f110 && cp <= 0x1f12d } || { cp >= 0x1f130 && cp <= 0x1f169 } || { cp >= 0x1f170 && cp <= 0x1f18d } || { cp >= 0x1f18f && cp <= 0x1f190 } || { cp >= 0x1f19b && cp <= 0x1f1ac } || { cp >= 0xe0100 && cp <= 0xe01ef } || { cp >= 0xf0000 && cp <= 0xffffd } || { cp >= 0x100000 && cp <= 0x10FFFD } } }}
fn is_wide(cp: Int) -> Bool { case cp <= 0x1ffff { True -> table_lookup(tables.wide, cp) False -> cp >= 0x20000 && cp <= 0x40000 }}
fn is_spacing_mark(cp: Int) -> Bool { case cp <= 0x1ffff { True -> table_lookup(tables.spacing_mark, cp) False -> False }}
// -- FFI ----------------------------------------------------------------------
// size is not supported@external(javascript, "./string_width_ffi.mjs", "table_lookup")fn table_lookup(table: BitArray, value: Int) -> Bool { let hi = int.bitwise_shift_right(value, 8) let md = int.bitwise_shift_right(int.bitwise_and(value, 0xff), 3) let lo = int.bitwise_and(value, 7)
let lvl1_skip = hi * 8 let assert <<_:size(lvl1_skip), lvl1:int, _:bits>> = table
let lvl2_skip = { lvl1 * 32 + md } * 8 let assert <<_:size(lvl2_skip), lvl3:int, _:bits>> = table int.bitwise_shift_right(lvl3, lo) % 2 != 0}
@external(javascript, "./string_width_ffi.mjs", "fold_graphemes")fn fold_graphemes( str: String, state: state, fun: fn(state, String) -> state,) -> state { case string.pop_grapheme(str) { Ok(#(grapheme, str)) -> fold_graphemes(str, fun(state, grapheme), fun) Error(Nil) -> state }}
@external(erlang, "string_width_ffi", "fold_codepoints")@external(javascript, "./string_width_ffi.mjs", "fold_codepoints")fn fold_codepoints( str: String, state: state, fun: fn(state, Int) -> state,) -> state { use state, cp <- list.fold(string.to_utf_codepoints(str), state) fun(state, string.utf_codepoint_to_int(cp))}
@external(erlang, "string_width_ffi", "fold_bytes")@external(javascript, "./string_width_ffi.mjs", "fold_bytes")fn fold_bytes(str: String, state: state, fun: fn(state, Int) -> state) -> state
@external(erlang, "erlang", "split_binary")@external(javascript, "./string_width_ffi.mjs", "unsafe_split")fn unsafe_split(str: String, at: Int) -> #(String, String)
@external(erlang, "string_width_ffi", "utf_codepoint_to_string")@external(javascript, "./string_width_ffi.mjs", "utf_codepoint_to_string")fn utf_codepoint_to_string(cp: Int) -> String