-module(spruce@align). -compile([no_auto_import, nowarn_unused_vars, nowarn_unused_function, nowarn_nomatch, inline]). -define(FILEPATH, "src/spruce/align.gleam"). -export([visual_length/1, pad_right/2, pad_left/2, pad_center/2, height/1, size/1, truncate/3, wrap/2]). -export_type([piece/0, piece_kind/0, escape_state/0]). -if(?OTP_RELEASE >= 27). -define(MODULEDOC(Str), -moduledoc(Str)). -define(DOC(Str), -doc(Str)). -else. -define(MODULEDOC(Str), -compile([])). -define(DOC(Str), -compile([])). -endif. ?MODULEDOC(" ANSI-aware text alignment helpers.\n"). -type piece() :: {word, binary()} | {spaces, binary()}. -type piece_kind() :: no_piece | word_piece | space_piece. -type escape_state() :: normal_text | escape_start | csi_escape | osc_escape | osc_escape_after_esc. -file("src/spruce/align.gleam", 149). -spec in_range(integer(), integer(), integer()) -> boolean(). in_range(Value, First, Last) -> (Value >= First) andalso (Value =< Last). -file("src/spruce/align.gleam", 76). -spec is_escape_final(binary()) -> boolean(). is_escape_final(Grapheme) -> case gleam@string:to_utf_codepoints(Grapheme) of [] -> false; [Codepoint | _] -> in_range(gleam_stdlib:identity(Codepoint), 16#40, 16#7e) end. -file("src/spruce/align.gleam", 84). -spec step_escape_state(escape_state(), binary()) -> escape_state(). step_escape_state(State, Char) -> case State of normal_text -> case Char of <<"\x{001b}"/utf8>> -> escape_start; _ -> normal_text end; escape_start -> case Char of <<"["/utf8>> -> csi_escape; <<"]"/utf8>> -> osc_escape; <<"\x{001b}"/utf8>> -> escape_start; _ -> normal_text end; csi_escape -> case is_escape_final(Char) of true -> normal_text; false -> csi_escape end; osc_escape -> case Char of <<"\x{0007}"/utf8>> -> normal_text; <<"\x{001b}"/utf8>> -> osc_escape_after_esc; _ -> osc_escape end; osc_escape_after_esc -> case Char of <<"\\"/utf8>> -> normal_text; <<"\x{0007}"/utf8>> -> normal_text; <<"\x{001b}"/utf8>> -> osc_escape_after_esc; _ -> osc_escape end end. -file("src/spruce/align.gleam", 132). -spec is_wide(integer()) -> boolean(). is_wide(Codepoint) -> ((((((((((((in_range(Codepoint, 16#1100, 16#115f) orelse in_range( Codepoint, 16#2e80, 16#303e )) orelse in_range(Codepoint, 16#3041, 16#33ff)) orelse in_range(Codepoint, 16#3400, 16#4dbf)) orelse in_range(Codepoint, 16#4e00, 16#9fff)) orelse in_range(Codepoint, 16#a000, 16#a4cf)) orelse in_range(Codepoint, 16#ac00, 16#d7a3)) orelse in_range(Codepoint, 16#f900, 16#faff)) orelse in_range(Codepoint, 16#fe10, 16#fe19)) orelse in_range(Codepoint, 16#fe30, 16#fe6f)) orelse in_range(Codepoint, 16#ff00, 16#ff60)) orelse in_range(Codepoint, 16#ffe0, 16#ffe6)) orelse in_range(Codepoint, 16#1f300, 16#1faff)) orelse in_range(Codepoint, 16#20000, 16#3fffd). -file("src/spruce/align.gleam", 123). -spec is_zero_width(integer()) -> boolean(). is_zero_width(Codepoint) -> ((((in_range(Codepoint, 16#0300, 16#036f) orelse (Codepoint =:= 16#0489)) orelse in_range(Codepoint, 16#200b, 16#200f)) orelse (Codepoint =:= 16#200d)) orelse in_range(Codepoint, 16#fe00, 16#fe0f)) orelse (Codepoint =:= 16#feff). -file("src/spruce/align.gleam", 51). -spec display_width(binary()) -> integer(). display_width(Grapheme) -> case gleam@string:to_utf_codepoints(Grapheme) of [] -> 0; [Codepoint | _] -> Codepoint@1 = gleam_stdlib:identity(Codepoint), case is_zero_width(Codepoint@1) of true -> 0; false -> case is_wide(Codepoint@1) of true -> 2; false -> 1 end end end. -file("src/spruce/align.gleam", 35). -spec count_visible(list(binary()), escape_state(), integer()) -> integer(). count_visible(Chars, State, Count) -> case Chars of [] -> Count; [Char | Rest] -> case State of normal_text -> case Char of <<"\x{001b}"/utf8>> -> count_visible( Rest, step_escape_state(normal_text, Char), Count ); _ -> count_visible( Rest, normal_text, Count + display_width(Char) ) end; _ -> count_visible(Rest, step_escape_state(State, Char), Count) end end. -file("src/spruce/align.gleam", 69). -spec is_ascii(binary()) -> boolean(). is_ascii(Text) -> _pipe = gleam@string:to_utf_codepoints(Text), gleam@list:fold( _pipe, true, fun(All_ascii, Codepoint) -> All_ascii andalso (gleam_stdlib:identity(Codepoint) < 16#80) end ). -file("src/spruce/align.gleam", 28). ?DOC(" The visual length of a string, excluding ANSI escape codes.\n"). -spec visual_length(binary()) -> integer(). visual_length(Text) -> case gleam_stdlib:contains_string(Text, <<"\x{001b}"/utf8>>) orelse not is_ascii( Text ) of true -> count_visible(gleam@string:to_graphemes(Text), normal_text, 0); false -> string:length(Text) end. -file("src/spruce/align.gleam", 155). ?DOC( " Pad `text` on the right with spaces until it reaches `width` visual columns.\n" " Text already at or beyond `width` is returned unchanged.\n" ). -spec pad_right(binary(), integer()) -> binary(). pad_right(Text, Width) -> Padding = Width - visual_length(Text), case Padding > 0 of true -> <>, Padding))/binary>>; false -> Text end. -file("src/spruce/align.gleam", 165). ?DOC( " Pad `text` on the left with spaces until it reaches `width` visual columns.\n" " Text already at or beyond `width` is returned unchanged.\n" ). -spec pad_left(binary(), integer()) -> binary(). pad_left(Text, Width) -> Padding = Width - visual_length(Text), case Padding > 0 of true -> <<(gleam@string:repeat(<<" "/utf8>>, Padding))/binary, Text/binary>>; false -> Text end. -file("src/spruce/align.gleam", 176). ?DOC( " Pad `text` on both sides with spaces until it reaches `width` visual columns.\n" " When an odd number of spaces is needed, the extra space is placed on the\n" " right. Text already at or beyond `width` is returned unchanged.\n" ). -spec pad_center(binary(), integer()) -> binary(). pad_center(Text, Width) -> Padding = Width - visual_length(Text), case Padding > 0 of true -> Left = Padding div 2, Right = Padding - Left, <<<<(gleam@string:repeat(<<" "/utf8>>, Left))/binary, Text/binary>>/binary, (gleam@string:repeat(<<" "/utf8>>, Right))/binary>>; false -> Text end. -file("src/spruce/align.gleam", 189). ?DOC(" Count the number of lines in `text`.\n"). -spec height(binary()) -> integer(). height(Text) -> _pipe = Text, _pipe@1 = gleam@string:split(_pipe, <<"\n"/utf8>>), erlang:length(_pipe@1). -file("src/spruce/align.gleam", 196). ?DOC(" Return the widest visual line and the number of lines in `text`.\n"). -spec size(binary()) -> {integer(), integer()}. size(Text) -> Lines = gleam@string:split(Text, <<"\n"/utf8>>), Width = begin _pipe = Lines, _pipe@1 = gleam@list:map(_pipe, fun visual_length/1), gleam@list:fold(_pipe@1, 0, fun gleam@int:max/2) end, {Width, erlang:length(Lines)}. -file("src/spruce/align.gleam", 707). -spec chars_to_string(list(binary())) -> binary(). chars_to_string(Chars) -> gleam@string:join(Chars, <<""/utf8>>). -file("src/spruce/align.gleam", 615). -spec take_visible_loop( list(binary()), integer(), escape_state(), list(binary()) ) -> binary(). take_visible_loop(Chars, Remaining, State, Acc) -> case Chars of [] -> chars_to_string(lists:reverse(Acc)); [Char | Rest] -> case State of normal_text -> case Char of <<"\x{001b}"/utf8>> -> take_visible_loop( Rest, Remaining, step_escape_state(normal_text, Char), [Char | Acc] ); _ -> Char_width = display_width(Char), case (Char_width =:= 0) orelse (Char_width =< Remaining) of true -> take_visible_loop( Rest, Remaining - Char_width, normal_text, [Char | Acc] ); false -> take_visible_loop(Rest, 0, normal_text, Acc) end end; _ -> take_visible_loop( Rest, Remaining, step_escape_state(State, Char), [Char | Acc] ) end end. -file("src/spruce/align.gleam", 608). -spec take_visible(binary(), integer()) -> binary(). take_visible(Text, Width) -> case Width =< 0 of true -> take_visible_loop( gleam@string:to_graphemes(Text), 0, normal_text, [] ); false -> take_visible_loop( gleam@string:to_graphemes(Text), Width, normal_text, [] ) end. -file("src/spruce/align.gleam", 544). -spec sgr_has_param(binary(), binary()) -> boolean(). sgr_has_param(Escape, Param) -> ((gleam_stdlib:contains_string( Escape, <<<<"["/utf8, Param/binary>>/binary, "m"/utf8>> ) orelse gleam_stdlib:contains_string( Escape, <<<<"["/utf8, Param/binary>>/binary, ";"/utf8>> )) orelse gleam_stdlib:contains_string( Escape, <<<<";"/utf8, Param/binary>>/binary, "m"/utf8>> )) orelse gleam_stdlib:contains_string( Escape, <<<<";"/utf8, Param/binary>>/binary, ";"/utf8>> ). -file("src/spruce/align.gleam", 537). -spec is_sgr_reset(binary()) -> boolean(). is_sgr_reset(Escape) -> ((gleam_stdlib:contains_string(Escape, <<"[m"/utf8>>) orelse sgr_has_param( Escape, <<"0"/utf8>> )) orelse sgr_has_param(Escape, <<"39"/utf8>>)) orelse sgr_has_param(Escape, <<"49"/utf8>>). -file("src/spruce/align.gleam", 530). -spec update_sgr_active(binary(), binary(), boolean()) -> boolean(). update_sgr_active(Escape, Final, Active) -> case Final of <<"m"/utf8>> -> not is_sgr_reset(Escape); _ -> Active end. -file("src/spruce/align.gleam", 473). -spec has_open_sgr_loop(list(binary()), escape_state(), binary(), boolean()) -> boolean(). has_open_sgr_loop(Chars, State, Escape, Active) -> case Chars of [] -> Active; [Char | Rest] -> case State of normal_text -> case Char of <<"\x{001b}"/utf8>> -> has_open_sgr_loop( Rest, step_escape_state(normal_text, Char), Char, Active ); _ -> has_open_sgr_loop( Rest, normal_text, <<""/utf8>>, Active ) end; escape_start -> case Char of <<"["/utf8>> -> has_open_sgr_loop( Rest, csi_escape, <>, Active ); <<"]"/utf8>> -> has_open_sgr_loop( Rest, osc_escape, <<""/utf8>>, Active ); _ -> has_open_sgr_loop( Rest, step_escape_state(escape_start, Char), <<""/utf8>>, Active ) end; csi_escape -> Escape@1 = <>, Active@1 = case is_escape_final(Char) of true -> update_sgr_active(Escape@1, Char, Active); false -> Active end, has_open_sgr_loop( Rest, step_escape_state(csi_escape, Char), Escape@1, Active@1 ); osc_escape -> has_open_sgr_loop( Rest, step_escape_state(State, Char), <<""/utf8>>, Active ); osc_escape_after_esc -> has_open_sgr_loop( Rest, step_escape_state(State, Char), <<""/utf8>>, Active ) end end. -file("src/spruce/align.gleam", 469). -spec has_open_sgr(binary()) -> boolean(). has_open_sgr(Text) -> has_open_sgr_loop( gleam@string:to_graphemes(Text), normal_text, <<""/utf8>>, false ). -file("src/spruce/align.gleam", 464). -spec close_open_sgr(binary()) -> binary(). close_open_sgr(Text) -> gleam@bool:guard( not has_open_sgr(Text), Text, fun() -> <> end ). -file("src/spruce/align.gleam", 212). ?DOC( " Truncate `text` to at most `width` visual columns, appending `ellipsis` when\n" " truncation is needed.\n" "\n" " ANSI escape sequences do not count toward the width and are never split.\n" " If `ellipsis` is wider than `width`, the ellipsis itself is visibly\n" " truncated to fit. Widths less than or equal to zero return an empty string.\n" ). -spec truncate(binary(), integer(), binary()) -> binary(). truncate(Text, Width, Ellipsis) -> gleam@bool:guard( Width =< 0, <<""/utf8>>, fun() -> gleam@bool:guard( visual_length(Text) =< Width, Text, fun() -> Ellipsis_width = visual_length(Ellipsis), case Ellipsis_width >= Width of true -> take_visible(Ellipsis, Width); false -> <<(close_open_sgr( take_visible(Text, Width - Ellipsis_width) ))/binary, Ellipsis/binary>> end end ) end ). -file("src/spruce/align.gleam", 354). -spec drop_spaces(list(binary())) -> list(binary()). drop_spaces(Chars) -> case Chars of [<<" "/utf8>> | Rest] -> drop_spaces(Rest); _ -> Chars end. -file("src/spruce/align.gleam", 345). -spec trim_trailing_spaces(binary()) -> binary(). trim_trailing_spaces(Text) -> _pipe = Text, _pipe@1 = gleam@string:to_graphemes(_pipe), _pipe@2 = lists:reverse(_pipe@1), _pipe@3 = drop_spaces(_pipe@2), _pipe@4 = lists:reverse(_pipe@3), chars_to_string(_pipe@4). -file("src/spruce/align.gleam", 338). -spec push_wrapped_line(binary(), list(binary())) -> list(binary()). push_wrapped_line(Line, Lines) -> case trim_trailing_spaces(Line) of <<""/utf8>> -> Lines; Trimmed -> [close_open_sgr(Trimmed) | Lines] end. -file("src/spruce/align.gleam", 663). -spec drop_visible_loop(list(binary()), integer(), escape_state()) -> binary(). drop_visible_loop(Chars, Remaining, State) -> case Chars of [] -> <<""/utf8>>; [Char | Rest] -> case State of normal_text -> case Remaining =< 0 of true -> chars_to_string(Chars); false -> case Char of <<"\x{001b}"/utf8>> -> drop_visible_loop( Rest, Remaining, step_escape_state(normal_text, Char) ); _ -> Char_width = display_width(Char), case Char_width > Remaining of true -> chars_to_string(Chars); false -> drop_visible_loop( Rest, Remaining - Char_width, normal_text ) end end end; _ -> drop_visible_loop( Rest, Remaining, step_escape_state(State, Char) ) end end. -file("src/spruce/align.gleam", 659). -spec drop_visible(binary(), integer()) -> binary(). drop_visible(Text, Width) -> drop_visible_loop(gleam@string:to_graphemes(Text), Width, normal_text). -file("src/spruce/align.gleam", 580). -spec first_positive_width_loop(list(binary()), escape_state()) -> integer(). first_positive_width_loop(Chars, State) -> case Chars of [] -> 0; [Char | Rest] -> case State of normal_text -> case Char of <<"\x{001b}"/utf8>> -> first_positive_width_loop( Rest, step_escape_state(normal_text, Char) ); _ -> Width = display_width(Char), case Width of 0 -> first_positive_width_loop(Rest, normal_text); _ -> Width end end; _ -> first_positive_width_loop( Rest, step_escape_state(State, Char) ) end end. -file("src/spruce/align.gleam", 576). -spec first_positive_width(binary()) -> integer(). first_positive_width(Text) -> first_positive_width_loop(gleam@string:to_graphemes(Text), normal_text). -file("src/spruce/align.gleam", 551). -spec wrap_long_word(binary(), integer()) -> list(binary()). wrap_long_word(Word, Width) -> case visual_length(Word) =< Width of true -> [Word]; false -> Chunk = take_visible(Word, Width), case visual_length(Chunk) =:= 0 of true -> First_width = first_positive_width(Word), case First_width of 0 -> [Word]; _ -> [take_visible(Word, First_width) | wrap_long_word( drop_visible(Word, First_width), Width )] end; false -> [Chunk | wrap_long_word(drop_visible(Word, Width), Width)] end end. -file("src/spruce/align.gleam", 452). -spec add_wrapped_chunks(list(binary()), list(binary())) -> {binary(), integer(), list(binary())}. add_wrapped_chunks(Chunks, Lines) -> case Chunks of [] -> {<<""/utf8>>, 0, Lines}; [Chunk] -> {Chunk, visual_length(Chunk), Lines}; [Chunk@1 | Rest] -> add_wrapped_chunks(Rest, [close_open_sgr(Chunk@1) | Lines]) end. -file("src/spruce/align.gleam", 248). -spec wrap_pieces(list(piece()), integer(), binary(), integer(), list(binary())) -> list(binary()). wrap_pieces(Pieces, Width, Current, Current_width, Lines) -> case Pieces of [] -> case Current of <<""/utf8>> -> lists:reverse(Lines); _ -> lists:reverse([Current | Lines]) end; [{spaces, Spaces} | Rest] -> Spaces_width = visual_length(Spaces), case (Current_width + Spaces_width) =< Width of true -> wrap_pieces( Rest, Width, <>, Current_width + Spaces_width, Lines ); false -> case Current_width of 0 -> {Next_current, Next_width, Next_lines} = add_wrapped_chunks( wrap_long_word(Spaces, Width), Lines ), wrap_pieces( Rest, Width, Next_current, Next_width, Next_lines ); _ -> wrap_pieces( Rest, Width, <<""/utf8>>, 0, push_wrapped_line(Current, Lines) ) end end; [{word, Word} | Rest@1] -> Word_width = visual_length(Word), case Word_width > Width of true -> case Current_width of 0 -> {Next_current@1, Next_width@1, Next_lines@1} = add_wrapped_chunks( wrap_long_word(Word, Width), Lines ), wrap_pieces( Rest@1, Width, Next_current@1, Next_width@1, Next_lines@1 ); _ -> wrap_pieces( Pieces, Width, <<""/utf8>>, 0, push_wrapped_line(Current, Lines) ) end; false -> case (Current_width + Word_width) =< Width of true -> wrap_pieces( Rest@1, Width, <>, Current_width + Word_width, Lines ); false -> wrap_pieces( Pieces, Width, <<""/utf8>>, 0, push_wrapped_line(Current, Lines) ) end end end. -file("src/spruce/align.gleam", 437). -spec push_piece(piece_kind(), binary(), list(piece())) -> list(piece()). push_piece(Kind, Current, Pieces) -> case Current of <<""/utf8>> -> Pieces; _ -> case Kind of space_piece -> [{spaces, Current} | Pieces]; _ -> [{word, Current} | Pieces] end end. -file("src/spruce/align.gleam", 422). -spec add_piece_grapheme( binary(), piece_kind(), binary(), piece_kind(), list(piece()) ) -> {binary(), piece_kind(), list(piece())}. add_piece_grapheme(Char, Char_kind, Current, Kind, Pieces) -> case {Kind, Char_kind} of {no_piece, _} -> {Char, Char_kind, Pieces}; {space_piece, space_piece} -> {<>, Kind, Pieces}; {word_piece, word_piece} -> {<>, Kind, Pieces}; {_, _} -> {Char, Char_kind, push_piece(Kind, Current, Pieces)} end. -file("src/spruce/align.gleam", 365). -spec split_pieces_loop( list(binary()), escape_state(), binary(), piece_kind(), list(piece()) ) -> list(piece()). split_pieces_loop(Chars, State, Current, Kind, Pieces) -> case Chars of [] -> lists:reverse(push_piece(Kind, Current, Pieces)); [Char | Rest] -> case State of normal_text -> case Char of <<"\x{001b}"/utf8>> -> Kind@1 = case Kind of no_piece -> word_piece; _ -> Kind end, split_pieces_loop( Rest, step_escape_state(normal_text, Char), <>, Kind@1, Pieces ); <<" "/utf8>> -> {Current@1, Kind@2, Pieces@1} = add_piece_grapheme( <<" "/utf8>>, space_piece, Current, Kind, Pieces ), split_pieces_loop( Rest, normal_text, Current@1, Kind@2, Pieces@1 ); _ -> {Current@2, Kind@3, Pieces@2} = add_piece_grapheme( Char, word_piece, Current, Kind, Pieces ), split_pieces_loop( Rest, normal_text, Current@2, Kind@3, Pieces@2 ) end; _ -> split_pieces_loop( Rest, step_escape_state(State, Char), <>, Kind, Pieces ) end end. -file("src/spruce/align.gleam", 361). -spec split_pieces(binary()) -> list(piece()). split_pieces(Text) -> split_pieces_loop( gleam@string:to_graphemes(Text), normal_text, <<""/utf8>>, no_piece, [] ). -file("src/spruce/align.gleam", 241). -spec wrap_line(binary(), integer()) -> binary(). wrap_line(Line, Width) -> _pipe = Line, _pipe@1 = split_pieces(_pipe), _pipe@2 = wrap_pieces(_pipe@1, Width, <<""/utf8>>, 0, []), gleam@string:join(_pipe@2, <<"\n"/utf8>>). -file("src/spruce/align.gleam", 233). ?DOC( " Wrap `text` to `width` visual columns.\n" "\n" " ANSI escape sequences do not count toward the width and are never split.\n" " Words longer than `width` are hard-wrapped at visible column boundaries.\n" " Widths less than or equal to zero return the input unchanged.\n" ). -spec wrap(binary(), integer()) -> binary(). wrap(Text, Width) -> gleam@bool:guard(Width =< 0, Text, fun() -> _pipe = Text, _pipe@1 = gleam@string:split(_pipe, <<"\n"/utf8>>), _pipe@2 = gleam@list:map( _pipe@1, fun(_capture) -> wrap_line(_capture, Width) end ), gleam@string:join(_pipe@2, <<"\n"/utf8>>) end).