• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

fhunleth / tablet / 079923cd-9f56-40df-8410-457057622355

10 Jun 2025 03:00AM UTC coverage: 98.529% (+1.4%) from 97.156%
079923cd-9f56-40df-8410-457057622355

Pull #13

circleci

fhunleth
Pause and resume ANSI coloring across multi-line cells
Pull Request #13: Make fit_to_width more generic and support multi-line cells

109 of 110 new or added lines in 3 files covered. (99.09%)

3 existing lines in 1 file now uncovered.

268 of 272 relevant lines covered (98.53%)

344.47 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.35
/lib/tablet.ex
1
# SPDX-FileCopyrightText: 2025 Frank Hunleth
2
#
3
# SPDX-License-Identifier: Apache-2.0
4
#
5
defmodule Tablet do
6
  @moduledoc """
7
  A tiny tabular data renderer
8

9
  This module renders tabular data as text for output to the console or any
10
  where else. Give it data in either of the following common tabular data
11
  shapes:
12

13
  ```
14
  # List of matching maps (atom or string keys)
15
  data = [
16
    %{"id" => 1, "name" => "Puck"},
17
    %{"id" => 2, "name" => "Nick Bottom"}
18
  ]
19

20
  # List of matching key-value lists
21
  data = [
22
    [{"id", 1}, {"name", "Puck"}],
23
    [{"id", 2}, {"name", "Nick Bottom"}]
24
  ]
25
  ```
26

27
  Then call `Tablet.puts/2`:
28

29
  ```
30
  Tablet.puts(data)
31
  #=> id  name
32
  #=> 1   Puck
33
  #=> 2   Nick Bottom
34
  ```
35

36
  While this shows a table with minimal styling, it's possible to create
37
  fancier tables with colors, borders and more.
38

39
  Here are some of Tablet's features:
40

41
  * `Kino.DataTable`-inspired API for ease of switching between Livebook and console output
42
  * Automatic column sizing
43
  * Multi-column wrapping for tables with many rows and few columns
44
  * Data eliding for long strings
45
  * Customizable data formatting and styling
46
  * Unicode support for emojis and other wide characters
47
  * `t:IO.ANSI.ansidata/0` throughout
48
  * Small. No runtime dependencies.
49

50
  While seemingly an implementation detail, Tablet's use of `t:IO.ANSI.ansidata/0`
51
  allows a lot of flexibility in adding color and style to rendering. See `IO.ANSI`
52
  and the section below to learn more about this cool feature if you haven't used
53
  it before.
54

55
  ## Example
56

57
  Here's a more involved example:
58

59
  ```
60
  iex> data = [
61
  ...>   %{planet: "Mercury", orbital_period: 88},
62
  ...>   %{planet: "Venus", orbital_period: 224.701},
63
  ...>   %{planet: "Earth", orbital_period: 365.256},
64
  ...>   %{planet: "Mars", orbital_period: 686.971}
65
  ...> ]
66
  iex> formatter = fn
67
  ...>   :__header__, :planet -> {:ok, "Planet"}
68
  ...>   :__header__, :orbital_period -> {:ok, "Orbital Period"}
69
  ...>   :orbital_period, value -> {:ok, "\#{value} days"}
70
  ...>   _, _ -> :default
71
  ...> end
72
  iex> Tablet.render(data, keys: [:planet, :orbital_period], formatter: formatter)
73
  ...>    |> IO.ANSI.format(false)
74
  ...>    |> IO.chardata_to_string()
75
  "Planet   Orbital Period\n" <>
76
  "Mercury  88 days       \n" <>
77
  "Venus    224.701 days  \n" <>
78
  "Earth    365.256 days  \n" <>
79
  "Mars     686.971 days  \n"
80
  ```
81

82
  Note that normally you'd call `IO.ANSI.format/2` without passing `false` to
83
  get colorized output and also call `IO.puts/2` to write to a terminal.
84

85
  ## Data formatting and column headers
86

87
  Tablet naively converts data values and constructs column headers to
88
  `t:IO.ANSI.ansidata/0`. This may not be what you want. To customize this,
89
  pass a 2-arity function using the `:formatter` option. That function takes
90
  the key and value as arguments and should return `{:ok, ansidata}`. The
91
  special key `:__header__` is passed when constructing header row. Return
92
  `:default` to use the default conversion.
93

94
  ## Styling
95

96
  Various table output styles are supported by supplying a `:style` function.
97
  The following are included:
98

99
  * `compact/3` - a minimal table style with underlined headers (default)
100
  * `markdown/3` - GitHub-flavored markdown table style
101

102
  ## Ansidata
103

104
  Tablet takes advantage of `t:IO.ANSI.ansidata/0` everywhere. This makes it
105
  easy to apply styling, colorization, and other transformations. However,
106
  it can be hard to read. It's highly recommended to either call `simplify/1` to
107
  simplify the output for review or to call `IO.ANSI.format/2` and then
108
  `IO.puts/2` to print it.
109

110
  In a nutshell, `t:IO.ANSI.ansidata/0` lets you create lists of strings to
111
  print and intermix atoms like `:red` or `:blue` to indicate where ANSI escape
112
  sequences should be inserted if supported. Tablet actually doesn't know what
113
  any of the atoms means and passes them through. Elixir's `IO.ANSI` module
114
  does all of the work. If fact, if you find `IO.ANSI` too limited, then you
115
  could use an alternative like [bunt](https://hex.pm/packages/bunt) and
116
  include atoms like `:chartreuse` which its formatter will understand.
117
  """
118

119
  alias Tablet.Styles
120

121
  @typedoc "An atom or string key that identifies a data column"
122
  @type key() :: atom() | String.t()
123
  @typedoc "One row of data represented in a map"
124
  @type matching_map() :: %{key() => any()}
125
  @typedoc "One row of data represented as a list of column ID, data tuples"
126
  @type matching_key_value_list() :: [{key(), any()}]
127
  @typedoc "Row-oriented data"
128
  @type data() :: [matching_map()] | [matching_key_value_list()]
129

130
  @typedoc """
131
  Column width values
132

133
  Column widths may be passed via the `:column_widths` options. The following
134
  values may also be specified:
135

136
  * `:default` - use the `:default_column_width`. This is the same as not
137
    specifying the column width
138
  * `:minimum` - make the column minimally fit the widest data element
139
  * `:expand` - expand the column so that the table is as wide as the console
140

141
  When multiple keys have the `:expand`, they'll be allocated equal space.
142
  """
143
  @type column_width() :: pos_integer() | :default | :minimum | :expand
144

145
  @typedoc """
146
  Style function callback
147

148
  Tablet calls this function after processing user options. The style
149
  function can modify anything in Tablet's state or wrap functions or
150
  do whatever it wants to adjust the output.
151

152
  Options are passed via the `:style_options` option which is included in the
153
  parameter.
154

155
  For most styles, the callback should set at least:
156

157
  * `:line_renderer` - a function that processes data for one line to the final output
158
  * `:style_padding` - horizontal padding map.
159
    * `:edge` - number of characters added on the left and right edges of the table
160
    * `:cell` - number of characters added between two cells
161
    * `:multi_column` - number of characters added between multi-column border cells
162
  """
163
  @type style_function() :: (t() -> t())
164

165
  @typedoc """
166
  Line rendering context
167

168
  The context is a simple map with fields that Tablet adds for conveying the
169
  section and row number that it's on. Row numbers start at 0. For normally
170
  rendered tables (`:wrap_across` unset or set to 1), the row number
171
  corresponds to row in the input data. For multi-column tables, the row
172
  is the left-most row in the group of rows that are rendered together.
173

174
  The `:slice` field indicates which line is being rendered within the row. For
175
  single line rows, it will be 0. For multi-line rows, it will be 0 for the first
176
  line, then 1, etc.
177

178
  Note that the line rendering function can output many lines of text per one input
179
  line. This is useful for adding borders.
180
  """
181
  @type line_context() :: %{
182
          section: :header | :body | :footer,
183
          row: non_neg_integer(),
184
          slice: non_neg_integer()
185
        }
186

187
  @typedoc """
188
  Row rendering callback function
189

190
  Tablet makes calls to the styling function for each line in the table
191
  starting with the header, then the rows (0 to N-1), and finally the footer.
192
  The second parameter is the `t:line_context/0` with position details.
193

194
  The third parameter is a list of `t:IO.ANSI.ansidata/0` values. When
195
  rendering multi-column tables (`:wrap_across` set to greater than 1), each
196
  item in the list corresponds to a set of columns. If your styling function
197
  doesn't care about multi-column tables, then call `List.flatten/1` on the
198
  parameter.
199

200
  The return value is always `t:IO.ANSI.ansidata/0`. It should contain a final
201
  new line since `Tablet` doesn't add anything.  Multiple lines can be returned
202
  if borders or more room for text is needed.
203

204
  When writing styling functions, it's recommended to pattern matching on the
205
  context.  Most of the time, you'll just need to know whether you're in the
206
  `:header` section or dealing with data rows. The context contains enough
207
  information to do more complicated things like match on even or odd lines and
208
  more if needed.
209
  """
210
  @type line_renderer() :: (t(), line_context(), [IO.ANSI.ansidata()] -> IO.ANSI.ansidata())
211

212
  @typedoc """
213
  Data formatter callback function
214

215
  This function is used for conversion of tabular data to `t:IO.ANSI.ansidata/0`.
216
  The special key `:__header__` is passed when formatting the column titles.
217

218
  The callback should return `{:ok, ansidata}` or `:default`.
219
  """
220
  @type formatter() :: (key(), any() -> {:ok, IO.ANSI.ansidata()} | :default)
221

222
  @typedoc """
223
  Justification for padding ansidata
224
  """
225
  @type justification() :: :left | :right | :center
226

227
  @typedoc """
228
  Table renderer state
229

230
  Fields:
231
  * `:data` - data rows
232
  * `:column_widths` - a map of keys to their desired column widths. See `t:column_width/0`.
233
  * `:keys` - a list of keys to include in the table for each record. The order is reflected in the rendered table. Optional
234
  * `:default_row_height` - number of rows or `:minimum` to set based on cell contents. Defaults to `:minimum`
235
  * `:default_column_width` - column width to use when unspecified in `:column_widths`. Defaults to `:minimum`
236
  * `:formatter` - a function to format the data in the table. The default is to convert everything to strings.
237
  * `:line_renderer` - a function that processes data for one line to the final output
238
  * `:name` - the name or table title. This can be any `t:IO.ANSI.ansidata/0` value.
239
  * `:style` - one of the built-in styles or a function to style the table. The default is `:compact`.
240
  * `:style_options` - styling options. See style documentation for details.
241
  * `:style_padding` - horizontal padding map
242
    * `:edge` - number of characters added on the left and right edges of the table
243
    * `:cell` - number of characters added between two cells
244
    * `:multi_column` - number of characters added between multi-column border cells
245
  * `:total_width` - the width of the console for use when expanding columns. The default is 0 to autodetect.
246
  * `:wrap_across` - the number of columns to wrap across in multi-column mode. The default is 1.
247
  """
248
  @type t :: %__MODULE__{
249
          column_widths: %{key() => column_width()},
250
          data: [matching_map()],
251
          default_column_width: non_neg_integer() | :minimum | :expand,
252
          default_row_height: pos_integer() | :minimum,
253
          formatter: formatter(),
254
          keys: nil | [key()],
255
          line_renderer: line_renderer(),
256
          name: IO.ANSI.ansidata(),
257
          style: atom() | style_function(),
258
          style_options: keyword(),
259
          style_padding: %{
260
            edge: non_neg_integer(),
261
            cell: non_neg_integer(),
262
            multi_column: non_neg_integer()
263
          },
264
          total_width: non_neg_integer(),
265
          wrap_across: pos_integer()
266
        }
267
  defstruct column_widths: %{},
268
            data: [],
269
            default_column_width: :minimum,
270
            default_row_height: :minimum,
271
            formatter: &Tablet.always_default_formatter/2,
272
            keys: nil,
273
            line_renderer: nil,
274
            name: [],
275
            style: &Tablet.Styles.compact/1,
276
            style_options: [],
277
            style_padding: %{edge: 1, cell: 2, multi_column: 2},
278
            total_width: 0,
279
            wrap_across: 1
280

281
  @doc """
282
  Print a table to the console
283

284
  Call this to quickly print tabular data to the console.
285

286
  This supports all of the options from `render/2`.
287

288
  Additional options:
289
  * `:ansi_enabled?` - force ANSI output. If unset, the terminal setting is used.
290
  """
291
  @spec puts(data(), keyword()) :: :ok
292
  def puts(data, options \\ []) do
293
    data
294
    |> render(options)
295
    |> IO.ANSI.format(Keyword.get(options, :ansi_enabled?, IO.ANSI.enabled?()))
296
    |> IO.write()
3✔
297
  end
298

299
  @doc """
300
  Render a table as `t:IO.ANSI.ansidata/0`
301

302
  This formats tabular data and returns it in a form that can be run through
303
  `IO.ANSI.format/2` for expansion of ANSI escape codes and then written to
304
  an IO device.
305

306
  Options:
307

308
  * `:column_widths` - a map of keys to their desired column widths. See `t:column_width/0`.
309
  * `:data` - tabular data
310
  * `:default_column_width` - default column width in characters
311
  * `:formatter` - if passing non-ansidata, supply a function to apply custom formatting
312
  * `:keys` - a list of keys to include in the table for each record. The order is reflected in the rendered table. Optional
313
  * `:name` - the name or table title. This can be any `t:IO.ANSI.ansidata/0` value. Not used by default style.
314
  * `:style` - see `t:style/0` for details on styling tables
315
  * `:total_width` - the total width of the table if any of the `:column_widths` is `:expand`. Defaults to the console width if needed.
316
  * `:wrap_across` - the number of columns to wrap across in multi-column mode
317
  """
318
  @spec render(data(), keyword()) :: IO.ANSI.ansidata()
319
  def render(data, options \\ []) do
320
    new([{:data, data} | options])
321
    |> to_ansidata()
86✔
322
  end
323

324
  @doc """
325
  Compute column widths
326

327
  This function is useful if you need to render more than one table
328
  with the same keys and want column widths to stay the same. It
329
  takes the same options as `render/2`. It returns a fully resolved
330
  version of the `:column_widths` option that can be passed to
331
  future calls to `render/2` and `puts/2`.
332
  """
333
  @spec compute_column_widths(data(), keyword()) :: %{key() => pos_integer()}
334
  def compute_column_widths(data, options \\ []) do
335
    table =
6✔
336
      new([{:data, data} | options])
337
      |> fill_in_keys()
338
      |> then(& &1.style.(&1))
6✔
339
      |> calculate_column_widths()
340

341
    table.column_widths
6✔
342
  end
343

344
  defp new(options) do
345
    simple_opts =
92✔
346
      options
347
      |> Keyword.take([
348
        :column_widths,
349
        :default_column_width,
350
        :default_row_height,
351
        :formatter,
352
        :keys,
353
        :name,
354
        :style,
355
        :style_options,
356
        :total_width,
357
        :wrap_across
358
      ])
359
      |> Enum.map(&normalize/1)
360

361
    data_option = [{:data, normalize_data(options[:data])}]
80✔
362
    struct(__MODULE__, data_option ++ simple_opts)
79✔
363
  end
364

365
  defp normalize({:column_widths, v} = opt) when is_map(v), do: opt
7✔
366

367
  defp normalize({:default_column_width, v} = opt)
368
       when (is_integer(v) and v >= 0) or v in [:expand, :minimum],
369
       do: opt
5✔
370

371
  defp normalize({:default_row_height, v} = opt)
372
       when (is_integer(v) and v > 0) or v == :minimum,
373
       do: opt
1✔
374

375
  defp normalize({:formatter, v} = opt) when is_function(v, 2), do: opt
3✔
376
  defp normalize({:keys, v} = opt) when is_list(v), do: opt
14✔
377
  defp normalize({:name, v} = opt) when is_binary(v) or is_list(v), do: opt
23✔
378
  defp normalize({:style, v} = opt) when is_function(v, 1), do: opt
1✔
379
  defp normalize({:style, v}) when is_atom(v), do: {:style, Styles.resolve(v)}
59✔
380
  defp normalize({:style_options, v} = opt) when is_list(v), do: opt
1✔
381
  defp normalize({:total_width, v} = opt) when is_integer(v) and v >= 0, do: opt
10✔
382
  defp normalize({:wrap_across, v} = opt) when is_integer(v) and v >= 1, do: opt
17✔
383

384
  defp normalize({key, value}) do
385
    raise ArgumentError, "Unexpected value passed to #{inspect(key)}: #{inspect(value)}"
11✔
386
  end
387

388
  defp normalize_data([row | _] = d) when is_map(row), do: d
64✔
389
  defp normalize_data(d) when is_list(d), do: Enum.map(d, &Map.new(&1))
15✔
390

391
  defp normalize_data(_) do
392
    raise ArgumentError, "Expecting data as a list of maps or lists of key, value tuple lists."
1✔
393
  end
394

395
  defp fill_in_keys(table) do
396
    case table.keys do
79✔
397
      nil -> %{table | keys: keys_from_data(table.data)}
65✔
398
      _ -> table
14✔
399
    end
400
  end
401

402
  defp keys_from_data(data) do
403
    data |> Enum.reduce(%{}, &Map.merge/2) |> Map.keys() |> Enum.sort()
65✔
404
  end
405

406
  defp calculate_column_widths(table) do
407
    non_expanded_widths =
79✔
408
      Enum.map(table.keys, &update_column_width_pass_1(table, &1, table.column_widths[&1]))
79✔
409

410
    expanded_count = Enum.count(non_expanded_widths, fn {_, w} -> w == :expand end)
78✔
411

412
    if expanded_count > 0 do
78✔
413
      wrap_across = table.wrap_across
10✔
414
      non_expanded_width = non_expanded_widths |> Enum.map(&pre_expand_width/1) |> Enum.sum()
10✔
415

416
      width =
10✔
417
        wrap_across * non_expanded_width +
418
          table.style_padding.edge +
10✔
419
          wrap_across * (length(table.keys) - 1) * table.style_padding.cell +
10✔
420
          (wrap_across - 1) * table.style_padding.multi_column
10✔
421

422
      total_width = if table.total_width > 0, do: table.total_width, else: terminal_width()
10✔
423

424
      # Make sure the columns don't go below 0
425
      expansion = max(expanded_count * wrap_across, total_width - width)
10✔
426
      expansion_each = div(expansion, expanded_count * wrap_across)
10✔
427
      leftover = rem(expansion, expanded_count * wrap_across) |> div(wrap_across)
10✔
428
      last_expansion = final_expansion(non_expanded_widths)
10✔
429

430
      new_columns_widths =
10✔
431
        non_expanded_widths
432
        |> Enum.map(&update_expansion_column(&1, expansion_each))
29✔
433
        |> Map.new()
434
        |> Map.put(last_expansion, expansion_each + leftover)
435

436
      %{table | column_widths: new_columns_widths}
10✔
437
    else
438
      %{table | column_widths: Map.new(non_expanded_widths)}
68✔
439
    end
440
  end
441

442
  defp update_column_width_pass_1(table, key, :minimum) do
161✔
443
    {key,
444
     Enum.reduce(table.data, visual_width(format(table, :__header__, key)), fn row, acc ->
161✔
445
       max(acc, visual_width(format(table, key, row[key])))
568✔
446
     end)}
447
  end
448

449
  defp update_column_width_pass_1(_table, key, w) when is_integer(w) and w >= 1, do: {key, w}
2✔
450
  defp update_column_width_pass_1(_table, key, :expand), do: {key, :expand}
15✔
451

452
  defp update_column_width_pass_1(table, key, _),
453
    do: update_column_width_pass_1(table, key, table.default_column_width)
170✔
454

455
  defp pre_expand_width({_, :expand}), do: 0
15✔
456
  defp pre_expand_width({_, w}), do: w
14✔
457

458
  defp update_expansion_column({k, :expand}, w), do: {k, w}
15✔
459
  defp update_expansion_column(other, _w), do: other
14✔
460

461
  defp final_expansion(widths),
462
    do: widths |> Enum.reverse() |> Enum.find_value(fn {k, w} -> if w == :expand, do: k end)
10✔
463

464
  defp terminal_width() do
UNCOV
465
    case :io.columns() do
×
UNCOV
466
      {:ok, width} -> width
×
UNCOV
467
      {:error, _} -> 80
×
468
    end
469
  end
470

471
  defp to_ansidata(table) do
472
    table = table |> fill_in_keys() |> table.style.() |> calculate_column_widths()
73✔
473

474
    header =
72✔
475
      table.keys
72✔
476
      |> Enum.map(fn c ->
477
        s = format(table, :__header__, c)
163✔
478
        width = table.column_widths[c]
163✔
479
        Tablet.fit(s, {width, 1}, :left)
163✔
480
      end)
481
      |> List.duplicate(table.wrap_across)
72✔
482

483
    [
484
      table.line_renderer.(table, %{section: :header, row: 0, slice: 0}, header),
72✔
485
      render_rows(table, %{section: :body, row: 0, slice: 0}),
486
      table.line_renderer.(table, %{section: :footer, row: 0, slice: 0}, header)
72✔
487
    ]
488
  end
489

490
  defp render_rows(table, context) do
491
    # 1. Order the data in each row
492
    # 2. Group rows that are horizontally adjacent for multi-column rendering
493
    # 3. Style the groups
494
    table.data
72✔
495
    |> Enum.map(fn row -> for c <- table.keys, do: {c, format(table, c, row[c])} end)
225✔
496
    |> group_multi_column(table.keys, table.wrap_across)
72✔
497
    |> Enum.with_index(fn rows, i ->
72✔
498
      render_line(table, %{context | row: i}, rows)
167✔
499
    end)
500
  end
501

502
  defp render_line(table, context, rows) do
503
    height =
167✔
504
      case table.default_row_height do
167✔
505
        :minimum -> Enum.reduce(rows, 1, &max(&2, row_height(&1)))
164✔
506
        h -> h
3✔
507
      end
508

509
    fit_rows = fit_all_cells(table, rows, height)
167✔
510
    sliced_rows = fit_rows |> Enum.map(&zip_lists/1) |> zip_lists()
167✔
511

512
    Enum.with_index(sliced_rows, fn rows, i ->
167✔
513
      table.line_renderer.(table, %{context | slice: i}, rows)
201✔
514
    end)
515
  end
516

517
  defp zip_lists(l), do: Enum.zip_with(l, &Function.identity/1)
414✔
518

519
  defp row_height(row) do
520
    Enum.reduce(row, 1, fn {_, v}, acc -> max(acc, visual_height(v)) end)
244✔
521
  end
522

523
  defp fit_all_cells(table, rows, height) do
524
    rows
525
    |> Enum.map(fn row ->
167✔
526
      Enum.map(row, fn {c, v} ->
247✔
527
        width = table.column_widths[c]
668✔
528
        Tablet.fit(v, {width, height}, :left)
668✔
529
      end)
530
    end)
531
  end
532

533
  defp group_multi_column(data, keys, wrap_across)
534
       when data != [] and wrap_across > 1 do
535
    count = ceil(length(data) / wrap_across)
16✔
536
    empty_row = for c <- keys, do: {c, []}
16✔
537

538
    data
539
    |> Enum.chunk_every(count, count, Stream.cycle([empty_row]))
540
    |> Enum.zip_with(&Function.identity/1)
16✔
541
  end
542

543
  defp group_multi_column(data, _data_length, _wrap_across), do: Enum.map(data, &[&1])
56✔
544

545
  @doc false
546
  @spec always_default_formatter(key(), any()) :: :default
547
  def always_default_formatter(_key, _data), do: :default
1,395✔
548

549
  @doc false
550
  @spec format(t(), key(), any()) :: IO.ANSI.ansidata()
551
  def format(table, key, data) do
552
    case table.formatter.(key, data) do
1,496✔
553
      {:ok, ansidata} when is_list(ansidata) or is_binary(ansidata) ->
554
        ansidata
224✔
555

556
      :default ->
557
        default_format(key, data)
1,271✔
558

559
      other ->
560
        raise ArgumentError,
1✔
561
              "Expecting formatter to return {:ok, ansidata} or :default, but got #{inspect(other)}"
562
    end
563
  end
564

565
  @doc false
566
  @spec default_format(key(), any()) :: IO.ANSI.ansidata()
567
  def default_format(_id, data) when is_list(data) or is_binary(data), do: data
1,356✔
568
  def default_format(_id, nil), do: ""
6✔
569
  def default_format(_id, data) when is_atom(data), do: inspect(data)
81✔
570

571
  def default_format(_id, data) do
572
    case String.Chars.impl_for(data) do
32✔
573
      nil -> inspect(data)
2✔
574
      mod -> mod.to_string(data)
30✔
575
    end
576
  end
577

578
  @doc """
579
  Fit ansidata into the specified number of characters
580

581
  This function is useful for styling output to fit data into a cell.
582
  """
583
  @spec fit(IO.ANSI.ansidata(), {pos_integer(), pos_integer()}, justification()) ::
584
          IO.ANSI.ansidata()
585
  def fit(ansidata, {w, h}, justification)
586
      when is_integer(w) and w >= 0 and is_integer(h) and h > 0 do
587
    ansidata
588
    |> flatten()
589
    |> break_into_lines()
590
    |> pad_lines(h)
591
    |> Enum.map(fn line ->
898✔
592
      {trimmed, excess} = truncate(line, w, [])
1,012✔
593
      pad(trimmed, excess, justification)
1,012✔
594
    end)
595
  end
596

597
  # Take the first n lines and if there aren't n lines, add empty lines
598
  defp pad_lines(_, 0), do: []
898✔
599
  defp pad_lines([h | t], n), do: [h | pad_lines(t, n - 1)]
972✔
600
  defp pad_lines([], n), do: [[] | pad_lines([], n - 1)]
40✔
601

602
  # Flatten ansidata to a list of strings and ANSI codes
603
  defp flatten(ansidata), do: flatten(ansidata, []) |> Enum.reverse()
986✔
604
  defp flatten([], acc), do: acc
875✔
605
  defp flatten([h | t], acc), do: flatten(t, flatten(h, acc))
1,865✔
606
  defp flatten(a, acc), do: [a | acc]
1,976✔
607

608
  # Input: ansidata, output: list of ansidata split into lines
609
  # ANSI codes are re-issued on each line to preserve ANSI state when interleaved with other cells
610
  defp break_into_lines(ansidata), do: break_into_lines(ansidata, [], [], %{})
898✔
611

612
  defp break_into_lines([], current, lines, _ansi),
613
    do: Enum.reverse([Enum.reverse(current) | lines])
898✔
614

615
  defp break_into_lines(["" | t], current, lines, ansi),
616
    do: break_into_lines(t, current, lines, ansi)
27✔
617

618
  defp break_into_lines([h | t], current, lines, ansi) when is_binary(h) do
619
    case String.split(h, "\n", parts: 2) do
953✔
620
      [line] ->
621
        break_into_lines(t, [line | current], lines, ansi)
872✔
622

623
      [line, rest] ->
624
        break_into_lines(
81✔
625
          [rest | t],
626
          resume_ansi_r(ansi),
627
          [Enum.reverse([pause_ansi(ansi), line | current]) | lines],
628
          ansi
629
        )
630
    end
631
  end
632

633
  defp break_into_lines([h | t], current, lines, ansi),
634
    do: break_into_lines(t, [h | current], lines, merge_ansi(ansi, h))
69✔
635

636
  @ansi_colors [
637
    :black,
638
    :red,
639
    :green,
640
    :yellow,
641
    :blue,
642
    :magenta,
643
    :cyan,
644
    :white,
645
    :light_black,
646
    :light_red,
647
    :light_green,
648
    :light_yellow,
649
    :light_blue,
650
    :light_magenta,
651
    :light_cyan,
652
    :light_white
653
  ]
654
  @ansi_backgrounds [
655
    :black_background,
656
    :red_background,
657
    :green_background,
658
    :yellow_background,
659
    :blue_background,
660
    :magenta_background,
661
    :cyan_background,
662
    :white_background,
663
    :light_black_background,
664
    :light_red_background,
665
    :light_green_background,
666
    :light_yellow_background,
667
    :light_blue_background,
668
    :light_magenta_background,
669
    :light_cyan_background,
670
    :light_white_background
671
  ]
672

673
  defp merge_ansi(state, :default_color), do: Map.delete(state, :color)
6✔
674
  defp merge_ansi(state, :default_background), do: Map.delete(state, :background)
1✔
675
  defp merge_ansi(state, :not_italic), do: Map.delete(state, :italic)
1✔
676
  defp merge_ansi(state, :no_underline), do: Map.delete(state, :underline)
1✔
677
  defp merge_ansi(state, :underline), do: Map.put(state, :underline, [:underline])
1✔
678
  defp merge_ansi(state, :italic), do: Map.put(state, :italic, [:italic])
2✔
679
  defp merge_ansi(_state, :reset), do: %{}
1✔
680
  defp merge_ansi(state, c) when c in @ansi_colors, do: Map.put(state, :color, [c])
50✔
681
  defp merge_ansi(state, c) when c in @ansi_backgrounds, do: Map.put(state, :background, [c])
2✔
682
  defp merge_ansi(state, other), do: Map.update(state, :other, [other], &[other | &1])
4✔
683

684
  # Returns reverse order to apply for supporting "other" codes. Also see caller.
685
  defp resume_ansi_r(state), do: state |> Map.values() |> Enum.concat()
81✔
686

687
  defp pause_ansi(state), do: state |> Map.keys() |> Enum.map(&pause_atom/1)
81✔
688

689
  defp pause_atom(:color), do: :default_color
12✔
690
  defp pause_atom(:background), do: :default_background
1✔
691
  defp pause_atom(:italic), do: :not_italic
1✔
692
  defp pause_atom(:underline), do: :no_underline
2✔
693
  defp pause_atom(_other), do: :reset
3✔
694

695
  # Truncate flattened ansidata and add ellipsis if needed
696
  defp truncate([], len, acc), do: {Enum.reverse(acc), len}
1,012✔
697
  defp truncate([s | t], 0, acc) when is_binary(s), do: truncate(t, 0, acc)
29✔
698
  defp truncate([s | t], 0, acc), do: truncate(t, 0, [s | acc])
59✔
699

700
  defp truncate([s | t], len, acc) when is_binary(s) do
701
    {len, s, maybe} = truncate_graphemes(s, len)
918✔
702

703
    cond do
918✔
704
      len > 0 or maybe == nil -> truncate(t, len, [s | acc])
918✔
705
      more_chars?(t) -> truncate(t, 0, ["…", s | acc])
245✔
706
      true -> truncate(t, 0, [maybe, s | acc])
239✔
707
    end
708
  end
709

710
  defp truncate([s | t], len, acc), do: truncate(t, len, [s | acc])
110✔
711

712
  # Truncating strings requires handling variable-width graphemes
713
  # This returns the new remaining length, the truncated string, and if the string
714
  # fits perfectly, the last grapheme. The last grapheme might be replaced with an
715
  # ellipsis or not depending on whether there are more characters.
716
  defp truncate_graphemes(s, len) do
717
    {new_len, result, maybe} = truncate_graphemes(String.graphemes(s), len, [])
918✔
718
    {new_len, result |> Enum.reverse() |> Enum.join(), maybe}
918✔
719
  end
720

721
  defp truncate_graphemes([], len, acc), do: {len, acc, nil}
631✔
722
  defp truncate_graphemes(["\n" | _], _len, acc), do: {0, ["…" | acc], nil}
723

724
  defp truncate_graphemes([h | t], len, acc) do
725
    new_len = len - wcwidth(h)
5,054✔
726

727
    cond do
5,054✔
728
      new_len > 0 -> truncate_graphemes(t, new_len, [h | acc])
4,767✔
729
      new_len == 0 and (t == [] or t == ["\n"]) -> {0, acc, h}
287✔
730
      true -> {len - 1, ["…" | acc], nil}
42✔
731
    end
732
  end
733

734
  # Check if there are more characters (not ANSI codes)
735
  defp more_chars?([h | _]) when is_binary(h), do: h != ""
9✔
736
  defp more_chars?([_ | t]), do: more_chars?(t)
41✔
737
  defp more_chars?([]), do: false
236✔
738

739
  # Apply padding
740
  defp pad(ansidata, 0, _justification), do: ansidata
292✔
741
  defp pad(ansidata, len, :left), do: [ansidata, padding(len)]
697✔
742
  defp pad(ansidata, len, :right), do: [padding(len), ansidata]
3✔
743

744
  defp pad(ansidata, len, :center) do
745
    left = div(len, 2)
20✔
746
    [padding(left), ansidata, padding(len - left)]
747
  end
748

749
  defp padding(len), do: :binary.copy(" ", len)
740✔
750

751
  @doc """
752
  Convenience function for simplifying ansidata
753

754
  This is useful when debugging or checking output for unit tests. It flattens
755
  the list, combines strings, and removes redundant ANSI codes.
756
  """
757
  @spec simplify(IO.ANSI.ansidata()) :: IO.ANSI.ansidata()
758
  def simplify(ansidata) do
759
    ansidata |> flatten() |> simplify_ansi(:reset) |> simplify_text("")
88✔
760
  end
761

762
  defp simplify_ansi([last_ansi | t], last_ansi), do: simplify_ansi(t, last_ansi)
4✔
763
  defp simplify_ansi([h | t], _last_ansi) when is_atom(h), do: [h | simplify_ansi(t, h)]
176✔
764
  defp simplify_ansi([h | t], last_ansi), do: [h | simplify_ansi(t, last_ansi)]
828✔
765
  defp simplify_ansi([], _last_ansi), do: []
88✔
766

767
  defp simplify_text([h | t], acc) when is_binary(h), do: simplify_text(t, acc <> h)
823✔
768
  defp simplify_text([h | t], "") when is_atom(h), do: [h | simplify_text(t, "")]
85✔
769
  defp simplify_text([h | t], acc) when is_atom(h), do: [acc, h | simplify_text(t, "")]
91✔
770
  defp simplify_text([h | t], acc), do: simplify_text(t, <<acc::binary, h::utf8>>)
5✔
771
  defp simplify_text([], ""), do: []
27✔
772
  defp simplify_text([], acc), do: [acc]
61✔
773

774
  @doc """
775
  Calculate the size of ansidata when rendered
776

777
  The return value is the width and height.
778

779
  ## Examples
780

781
  ```
782
  iex> ansidata = ["Hello, ", :red, "world", :reset, "!"]
783
  iex> Tablet.visual_size(ansidata)
784
  {13, 1}
785
  ```
786
  """
787
  @spec visual_size(IO.ANSI.ansidata()) :: {non_neg_integer(), pos_integer()}
788
  def visual_size(ansidata) when is_binary(ansidata) or is_list(ansidata) do
789
    IO.ANSI.format(ansidata, false)
790
    |> IO.chardata_to_string()
791
    |> String.graphemes()
792
    |> measure(0, 0, 1)
1,801✔
793
  end
794

795
  defp visual_width(ansidata) do
796
    {width, _height} = visual_size(ansidata)
728✔
797
    width
728✔
798
  end
799

800
  defp visual_height(ansidata) do
801
    {_width, height} = visual_size(ansidata)
653✔
802
    height
653✔
803
  end
804

805
  # Add up the character widths and newlines
806
  defp measure([], current_width, w, h), do: {max(current_width, w), h}
1,801✔
807
  defp measure(["\n" | t], current_width, w, h), do: measure(t, 0, max(current_width, w), h + 1)
808
  defp measure([c | t], current_width, w, h), do: measure(t, current_width + wcwidth(c), w, h)
11,089✔
809

810
  # Simplistic wcwidth implementation based on https://www.cl.cam.ac.uk/~mgk25/ucs/wcwidth.c
811
  # with the addition of the 0x1f170..0x1f9ff range for emojis.
812
  # This currently assumes no 0-width characters.
813
  # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
814
  defp wcwidth(<<ucs::utf8, _::binary>>)
1,303✔
815
       when ucs >= 0x1100 and
816
              (ucs <= 0x115F or
817
                 ucs == 0x2329 or
818
                 ucs == 0x232A or
819
                 (ucs >= 0x2E80 and ucs <= 0xA4CF and ucs != 0x303F) or
820
                 (ucs >= 0xAC00 and ucs <= 0xD7A3) or
821
                 (ucs >= 0xF900 and ucs <= 0xFAFF) or
822
                 (ucs >= 0xFE10 and ucs <= 0xFE19) or
823
                 (ucs >= 0xFE30 and ucs <= 0xFE6F) or
824
                 (ucs >= 0xFF00 and ucs <= 0xFF60) or
825
                 (ucs >= 0xFFE0 and ucs <= 0xFFE6) or
826
                 (ucs >= 0x1F170 and ucs <= 0x1F9FF) or
827
                 (ucs >= 0x20000 and ucs <= 0x2FFFD) or
828
                 (ucs >= 0x30000 and ucs <= 0x3FFFD)),
829
       do: 2
830

831
  defp wcwidth(_), do: 1
14,840✔
832
end
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc