diff --git a/changelog.d/20260220_181030_sirosen_folded_table.md b/changelog.d/20260220_181030_sirosen_folded_table.md new file mode 100644 index 000000000..f1d328dea --- /dev/null +++ b/changelog.d/20260220_181030_sirosen_folded_table.md @@ -0,0 +1,6 @@ +### Enhancements + +* When using table output on narrow terminals, the Globus CLI will now stack + table elements in a new "folded table" layout. This behavior is only used + when the output device is a TTY. To disable the new output altogether, users + can set `GLOBUS_CLI_FOLD_TABLES=0`. diff --git a/src/globus_cli/termio/_display.py b/src/globus_cli/termio/_display.py index 66131ca11..61e938a46 100644 --- a/src/globus_cli/termio/_display.py +++ b/src/globus_cli/termio/_display.py @@ -6,10 +6,16 @@ import click import globus_sdk -from .context import outformat_is_json, outformat_is_text, outformat_is_unix +from .context import ( + fold_tables, + outformat_is_json, + outformat_is_text, + outformat_is_unix, +) from .field import Field from .printers import ( CustomPrinter, + FoldedTablePrinter, JsonPrinter, Printer, RecordListPrinter, @@ -172,7 +178,10 @@ def _resolve_printer( _assert_iterable(data) if text_mode == self.TABLE: - return TablePrinter(fields) + if fold_tables(): + return FoldedTablePrinter(fields) + else: + return TablePrinter(fields) if text_mode == self.RECORD_LIST: return RecordListPrinter(fields) diff --git a/src/globus_cli/termio/context.py b/src/globus_cli/termio/context.py index b533c94fb..ad429d677 100644 --- a/src/globus_cli/termio/context.py +++ b/src/globus_cli/termio/context.py @@ -108,3 +108,8 @@ def term_is_interactive() -> bool: return True return os.getenv("PS1") is not None + + +def fold_tables() -> bool | None: + val = os.getenv("GLOBUS_CLI_FOLD_TABLES") + return val is None or utils.str2bool(val) diff --git a/src/globus_cli/termio/printers/__init__.py b/src/globus_cli/termio/printers/__init__.py index cad92e994..87e55a7d1 100644 --- a/src/globus_cli/termio/printers/__init__.py +++ b/src/globus_cli/termio/printers/__init__.py @@ -1,5 +1,6 @@ from .base import Printer from .custom_printer import CustomPrinter +from .folded_table_printer import FoldedTablePrinter from .json_printer import JsonPrinter from .record_printer import RecordListPrinter, RecordPrinter from .table_printer import TablePrinter @@ -11,6 +12,7 @@ "JsonPrinter", "UnixPrinter", "TablePrinter", + "FoldedTablePrinter", "RecordPrinter", "RecordListPrinter", ) diff --git a/src/globus_cli/termio/printers/folded_table_printer.py b/src/globus_cli/termio/printers/folded_table_printer.py new file mode 100644 index 000000000..cf77a0e53 --- /dev/null +++ b/src/globus_cli/termio/printers/folded_table_printer.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +import collections +import dataclasses +import enum +import functools +import shutil +import typing as t + +import click + +from ..context import out_is_terminal, term_is_interactive +from ..field import Field +from .base import Printer + + +# the separator rows, including the top and bottom +class SeparatorRowType(enum.Enum): + # top of a table + box_top = enum.auto() + # between the header and the rest of the table (box drawing chars) + box_header_separator = enum.auto() + # the same, but for ASCII tables + ascii_header_separator = enum.auto() + # between rows of the table + box_row_separator = enum.auto() + # between element lines inside of a row of a table + box_intra_row_separator = enum.auto() + # bottom of a table + box_bottom = enum.auto() + + +@dataclasses.dataclass +class SeparatorRowStyle: + fill: str + leader: str + trailer: str + middle: str + + +SEPARATOR_ROW_STYLE_CHART: dict[SeparatorRowType, SeparatorRowStyle] = { + SeparatorRowType.ascii_header_separator: SeparatorRowStyle( + fill="-", leader="", trailer="", middle="+" + ), + SeparatorRowType.box_top: SeparatorRowStyle( + fill="═", leader="╒═", trailer="═╕", middle="╤" + ), + SeparatorRowType.box_header_separator: SeparatorRowStyle( + fill="═", leader="╞═", trailer="═╡", middle="╪" + ), + SeparatorRowType.box_row_separator: SeparatorRowStyle( + fill="─", leader="├─", trailer="─┤", middle="┼" + ), + SeparatorRowType.box_intra_row_separator: SeparatorRowStyle( + fill="─", leader="├─", trailer="─┤", middle="┼" + ), + SeparatorRowType.box_bottom: SeparatorRowStyle( + fill="─", leader="└─", trailer="─┘", middle="┴" + ), +} + + +class FoldedTablePrinter(Printer[t.Iterable[t.Any]]): + """ + A printer to render an iterable of objects holding tabular data with cells folded + together and stacked in the format: + + ╒════════════════╤════════════════╤════════════════╕ + │ │ + ├─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┤ + │ ╎ │ + ╞════════════════╪════════════════╪════════════════╡ + │ │ + ├─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┤ + │ ╎ │ + ├────────────────┼────────────────┼────────────────┤ + │ │ + ├─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┼─ ─ ─ ─ ─ ─ ─ ─┤ + │ ╎ │ + └────────────────┴────────────────┴────────────────┘ + + Rows are folded and stacked only if they won't fit in the output width. + + :param fields: a list of Fields with load and render instructions; one per column. + """ + + def __init__(self, fields: t.Iterable[Field], width: int | None = None) -> None: + self._fields = tuple(fields) + self._width = width or _get_terminal_content_width() + self._folding_enabled = _detect_folding_enabled() + + def echo(self, data: t.Iterable[t.Any], stream: t.IO[str] | None = None) -> None: + """ + Print out a rendered table. + + :param data: an iterable of data objects. + :param stream: an optional IO stream to write to. Defaults to stdout. + """ + echo = functools.partial(click.echo, file=stream) + + table = self._fold_table(RowTable.from_data(self._fields, data)) + col_widths = table.calculate_column_widths() + + # if folded, print a leading separator line + if table.folded: + echo(_separator_line(col_widths, row_type=SeparatorRowType.box_top)) + # print the header row and a separator + echo(table.header_row.serialize(col_widths)) + echo( + _separator_line( + col_widths, + row_type=( + SeparatorRowType.box_header_separator + if table.folded + else SeparatorRowType.ascii_header_separator + ), + ) + ) + + for row in table.content_rows[:-1]: + echo(row.serialize(col_widths)) + if table.folded: + echo( + _separator_line( + col_widths, + row_type=SeparatorRowType.box_row_separator, + ) + ) + # it is possible for a table to be empty, so check before attempting to + # serialize the last row + if table.content_rows: + echo(table.content_rows[-1].serialize(col_widths)) + if table.folded: + echo(_separator_line(col_widths, row_type=SeparatorRowType.box_bottom)) + + def _fold_table(self, table: RowTable) -> RowTable: + if not self._folding_enabled: + return table + + # if the table is initially narrow enough to fit, do not fold + if table.fits_in_width(self._width): + return table + + # try folding the table in half, and see if that fits + folded_table = table.fold_rows(2) + if folded_table.fits_in_width(self._width): + return folded_table + # if it's still too wide, fold in thirds and check that + else: + folded_table = table.fold_rows(3) + if folded_table.fits_in_width(self._width): + return folded_table + # if folded by thirds does not fit, fold all the way to a single column + else: + return table.fold_rows(table.num_columns) + + +class RowTable: + """ + A data structure to hold tabular data which has not yet been laid out. + + :param rows: a list of rows with table's contents, including the header row + :param folded: whether or not the table has been folded at all + :raises ValueError: if any rows have different numbers of columns. + """ + + def __init__(self, rows: tuple[Row, ...], folded: bool = False) -> None: + self.rows = rows + self.folded = folded + + self.num_columns = rows[0].num_cols + self.num_rows = len(rows) + + @property + def header_row(self) -> Row: + return self.rows[0] + + @property + def content_rows(self) -> tuple[Row, ...]: + return self.rows[1:] + + def fits_in_width(self, width: int) -> bool: + return all(x.min_rendered_width <= width for x in self.rows) + + def fold_rows(self, n: int) -> RowTable: + """Produce a new table with folded rows.""" + return RowTable(tuple(cell.fold(n) for cell in self.rows), folded=True) + + def calculate_column_widths(self) -> tuple[int, ...]: + return tuple( + max(0, *(self.rows[row].column_widths[col] for row in range(self.num_rows))) + for col in range(self.rows[0].num_cols) + ) + + @classmethod + def from_data(cls, fields: tuple[Field, ...], data: t.Iterable[t.Any]) -> RowTable: + """ + Create a RowTable from a list of fields and iterable of data objects. + + The data objects are serialized and discarded upon creation. + """ + rows = [] + # insert the header row + rows.append(Row((tuple(f.name for f in fields),))) + for data_obj in data: + rows.append(Row.from_source_data(fields, data_obj)) + + return cls(tuple(rows)) + + +class Row: + """A semantic row in the table of output, with a gridded internal layout.""" + + def __init__(self, grid: tuple[tuple[str, ...], ...]) -> None: + self.grid: tuple[tuple[str, ...], ...] = grid + + def __len__(self) -> int: + return sum(len(subrow) for subrow in self.grid) + + def __getitem__(self, coords: tuple[int, int]) -> str: + subrow, col = coords + return self.grid[subrow][col] + + @classmethod + def from_source_data(cls, fields: tuple[Field, ...], source: t.Any) -> Row: + return cls((tuple(field.serialize(source) for field in fields),)) + + def fold(self, n: int) -> Row: + """Fold the internal grid by N, stacking elements. Produces a new Row.""" + if self.is_folded: + raise ValueError( + "Rows can only be folded once. Use the original row to refold." + ) + return Row(tuple(self._split_level(self.grid[0], n))) + + def _split_level( + self, level: tuple[str, ...], modulus: int + ) -> t.Iterator[tuple[str, ...]]: + bins = collections.defaultdict(list) + for i, x in enumerate(level): + bins[i % modulus].append(x) + + for i in range(modulus): + yield tuple(bins[i]) + + @functools.cached_property + def is_folded(self) -> bool: + return len(self.grid) > 1 + + @functools.cached_property + def min_rendered_width(self) -> int: + decoration_length = 0 + if self.is_folded: + decoration_length = 4 + return sum(self.column_widths) + (3 * (self.num_cols - 1)) + decoration_length + + @functools.cached_property + def num_cols(self) -> int: + return max(0, *(len(subrow) for subrow in self.grid)) + + @functools.cached_property + def column_widths(self) -> tuple[int, ...]: + """The width of all columns in the row (as measured in this row).""" + return tuple(self._calculate_col_width(i) for i in range(self.num_cols)) + + def _calculate_col_width(self, idx: int) -> int: + return max( + 0, *(len(subrow[idx]) if idx < len(subrow) else 0 for subrow in self.grid) + ) + + def serialize(self, use_col_widths: tuple[int, ...]) -> str: + if len(self.grid) < 1: + raise ValueError("Invalid state. Cannot serialize an empty row.") + + if len(self.grid) == 1: + # format using ASCII characters (not folded) + return _format_subrow(self.grid[0], use_col_widths, "|", "", "") + + lines: list[str] = [] + + row_separator: str = _separator_line( + use_col_widths, SeparatorRowType.box_intra_row_separator + ) + for i, subrow in enumerate(self.grid): + if i > 0: + lines.append(row_separator) + # format using box drawing characters (part of folded output) + lines.append(_format_subrow(subrow, use_col_widths, "╎", "│ ", " │")) + return "\n".join(lines) + + +def _format_subrow( + subrow: tuple[str, ...], + use_col_widths: tuple[int, ...], + separator: str, + leader: str, + trailer: str, +) -> str: + line: list[str] = [] + for idx, width in enumerate(use_col_widths): + line.append((subrow[idx] if idx < len(subrow) else "").ljust(width)) + return leader + f" {separator} ".join(line) + trailer + + +@functools.cache +def _separator_line(col_widths: tuple[int, ...], row_type: SeparatorRowType) -> str: + style = SEPARATOR_ROW_STYLE_CHART[row_type] + + # in intra-row separator lines, they are drawn as dashed box char lines + if row_type is SeparatorRowType.box_intra_row_separator: + fill_column: t.Callable[[int], str] = _draw_dashed_box_line + # for all other cases, they're just a "flood fill" with the fill char + else: + + def fill_column(width: int) -> str: + return width * style.fill + + line_parts = [style.leader] + for col in col_widths[:-1]: + line_parts.append(fill_column(col)) + line_parts.append(f"{style.fill}{style.middle}{style.fill}") + line_parts.append(fill_column(col_widths[-1])) + line_parts.append(style.trailer) + return "".join(line_parts) + + +@functools.cache +def _draw_dashed_box_line(width: int) -> str: + # repeat with whitespace + sep = " ─" * width + sep = sep[:width] # trim to length + if sep[-1] == "─": # ensure it ends in whitespace + sep = sep[:-1] + " " + return sep + + +def _get_terminal_content_width() -> int: + """Get a content width for text output based on the terminal size. + + Uses the 90% of terminal width, if it can be detected. + """ + cols = shutil.get_terminal_size(fallback=(80, 20)).columns + return cols if cols < 88 else int(0.9 * cols) + + +def _detect_folding_enabled() -> bool: + return out_is_terminal() and term_is_interactive() diff --git a/tests/functional/endpoint/test_endpoint_search.py b/tests/functional/endpoint/test_endpoint_search.py index 96f8b7da0..72083a5f4 100644 --- a/tests/functional/endpoint/test_endpoint_search.py +++ b/tests/functional/endpoint/test_endpoint_search.py @@ -165,7 +165,7 @@ def test_search_shows_collection_id(run_line, singular_search_response): header_row = re.split(r"\s+\|\s+", header_line) assert header_row == ["ID", "Owner", "Display Name"] # the separator line is a series of dashes - separator_row = re.split(r"\s+\|\s+", separator_line) + separator_row = separator_line.split("-+-") assert len(separator_row) == 3 for separator in separator_row: assert set(separator) == {"-"} # exactly one character is used diff --git a/tests/functional/endpoint/test_storage_gateway_commands.py b/tests/functional/endpoint/test_storage_gateway_commands.py index 864a83a78..63666c8a6 100644 --- a/tests/functional/endpoint/test_storage_gateway_commands.py +++ b/tests/functional/endpoint/test_storage_gateway_commands.py @@ -17,7 +17,7 @@ def test_storage_gateway_list(add_gcs_login, run_line): expected = ( "ID | Display Name | High Assurance | Allowed Domains\n" # noqa: E501 - "------------------------------------ | ----------------- | -------------- | ---------------\n" # noqa: E501 + "-------------------------------------+-------------------+----------------+----------------\n" # noqa: E501 "a0cbde58-0183-11ea-92bd-9cb6d0d9fd63 | example gateway 1 | False | example.edu \n" # noqa: E501 "6840c8ba-eb98-11e9-b89c-9cb6d0d9fd63 | example gateway 2 | False | example.edu \n" # noqa: E501 ) diff --git a/tests/functional/endpoint/test_user_credential_commands.py b/tests/functional/endpoint/test_user_credential_commands.py index 283a4d51e..e6c6e6d13 100644 --- a/tests/functional/endpoint/test_user_credential_commands.py +++ b/tests/functional/endpoint/test_user_credential_commands.py @@ -19,7 +19,7 @@ def test_user_credential_list(add_gcs_login, run_line): expected = ( "ID | Display Name | Globus Identity | Local Username | Invalid\n" # noqa: E501 - "------------------------------------ | ---------------- | ------------------------------------ | -------------- | -------\n" # noqa: E501 + "-------------------------------------+------------------+--------------------------------------+----------------+--------\n" # noqa: E501 "af43d884-64a1-4414-897a-680c32374439 | posix_credential | 948847d4-ffcc-4ae0-ba3a-a4c88d480159 | testuser | False \n" # noqa: E501 "c96b8f70-1448-46db-89af-292623c93ee4 | s3_credential | 948847d4-ffcc-4ae0-ba3a-a4c88d480159 | testuser | False \n" # noqa: E501 ) diff --git a/tests/functional/flows/test_list_flows.py b/tests/functional/flows/test_list_flows.py index 606a61cfd..f1bbc1e32 100644 --- a/tests/functional/flows/test_list_flows.py +++ b/tests/functional/flows/test_list_flows.py @@ -10,7 +10,7 @@ def test_list_flows(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At \n" # noqa: E501 - "------- | --------------- | ---------------- | ------------------- | -------------------\n" # noqa: E501 + "--------+-----------------+------------------+---------------------+--------------------\n" # noqa: E501 "id-b | Fairytale Index | shrek@globus.org | 2007-05-18 00:00:00 | 2007-05-18 00:00:00\n" # noqa: E501 "id-a | Swamp Transfer | shrek@globus.org | 2001-04-01 00:00:00 | 2004-05-19 00:00:00\n" # noqa: E501 ) @@ -41,7 +41,7 @@ def test_list_flows_filter_role_single(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At \n" # noqa: E501 - "------- | --------------- | ------------------------ | ------------------- | -------------------\n" # noqa: E501 + "--------+-----------------+--------------------------+---------------------+--------------------\n" # noqa: E501 "id-bee | Recover Honey | barrybbenson@thehive.com | 2007-10-25 00:00:00 | 2007-10-25 00:00:00\n" # noqa: E501 "id-b | Fairytale Index | shrek@globus.org | 2007-05-18 00:00:00 | 2007-05-18 00:00:00\n" # noqa: E501 "id-a | Swamp Transfer | shrek@globus.org | 2001-04-01 00:00:00 | 2004-05-19 00:00:00\n" # noqa: E501 @@ -56,7 +56,7 @@ def test_list_flows_filter_role_multiple(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At \n" # noqa: E501 - "------- | --------------- | ---------------- | ------------------- | -------------------\n" # noqa: E501 + "--------+-----------------+------------------+---------------------+--------------------\n" # noqa: E501 "id-b | Fairytale Index | shrek@globus.org | 2007-05-18 00:00:00 | 2007-05-18 00:00:00\n" # noqa: E501 "id-a | Swamp Transfer | shrek@globus.org | 2001-04-01 00:00:00 | 2004-05-19 00:00:00\n" # noqa: E501 ) @@ -81,7 +81,7 @@ def test_list_flows_filter_fulltext(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At \n" # noqa: E501 - "------- | --------------- | ---------------- | ------------------- | -------------------\n" # noqa: E501 + "--------+-----------------+------------------+---------------------+--------------------\n" # noqa: E501 "id-b | Fairytale Index | shrek@globus.org | 2007-05-18 00:00:00 | 2007-05-18 00:00:00\n" # noqa: E501 ) @@ -135,7 +135,7 @@ def test_list_flows_empty_list(run_line): expected = ( "Flow ID | Title | Owner | Created At | Updated At\n" - "------- | ----- | ----- | ---------- | ----------\n" + "--------+-------+-------+------------+-----------\n" # noqa: E501 ) result = run_line("globus flows list") diff --git a/tests/functional/flows/test_list_runs.py b/tests/functional/flows/test_list_runs.py index ddf0309aa..6c60141a5 100644 --- a/tests/functional/flows/test_list_runs.py +++ b/tests/functional/flows/test_list_runs.py @@ -97,7 +97,7 @@ def test_list_runs_filter_role(run_line): expected = ( "Run ID | Flow Title | Run Label | Status \n" # noqa: E501 - "------------------------------------ | ------------ | ----------- | ---------\n" # noqa: E501 + "-------------------------------------+--------------+-------------+----------\n" # noqa: E501 f"{first_run_id} | My Cool Flow | My Cool Run | SUCCEEDED\n" # noqa: E501 ) assert result.output == expected diff --git a/tests/functional/flows/test_validate_flow.py b/tests/functional/flows/test_validate_flow.py index aa457110d..e1c24666e 100644 --- a/tests/functional/flows/test_validate_flow.py +++ b/tests/functional/flows/test_validate_flow.py @@ -229,7 +229,7 @@ def _parse_table_content(output): Parse the output of a command, searching for tables in the output and returning a list of headers and a list of rows (which are lists of cell values). - Expects a table with divider lines of the form `--- | --- | ---` and rows of the + Expects a table with divider lines of the form `----+-----+----` and rows of the form `value | value | value`. Returns a list of tuples where each tuple represents a parsed table and is @@ -240,7 +240,7 @@ def _parse_table_content(output): # Find the table divider lines = output.splitlines() divider_indices = [ - i for i, line in enumerate(lines) if re.fullmatch(r"-+ \| [-| ]*", line) + i for i, line in enumerate(lines) if re.fullmatch(r"\-+\+[\-\+]*", line) ] if not divider_indices: diff --git a/tests/unit/termio/printer/test_folded_table_printer.py b/tests/unit/termio/printer/test_folded_table_printer.py new file mode 100644 index 000000000..e9a48d3f7 --- /dev/null +++ b/tests/unit/termio/printer/test_folded_table_printer.py @@ -0,0 +1,199 @@ +from io import StringIO + +import pytest + +from globus_cli.termio import Field +from globus_cli.termio.printers import FoldedTablePrinter +from globus_cli.termio.printers.folded_table_printer import Row + + +@pytest.mark.parametrize( + "folding_enabled, width", + ( + (False, 10), + (False, 1000), + (True, 1000), + ), +) +def test_folded_table_printer_can_print_unfolded_output(folding_enabled, width): + fields = ( + Field("Column A", "a"), + Field("Column B", "b"), + Field("Column C", "c"), + ) + data = ( + {"a": 1, "b": 4, "c": 7}, + {"a": 2, "b": 5, "c": 8}, + {"a": 3, "b": 6, "c": 9}, + ) + printer = FoldedTablePrinter(fields=fields, width=width) + # override detection, set by test + printer._folding_enabled = folding_enabled + + with StringIO() as stream: + printer.echo(data, stream) + printed_table = stream.getvalue() + + # fmt: off + assert printed_table == ( + "Column A | Column B | Column C\n" + "---------+----------+---------\n" + "1 | 4 | 7 \n" + "2 | 5 | 8 \n" + "3 | 6 | 9 \n" + ) + # fmt: on + + +def test_folded_table_printer_can_fold_in_half(): + fields = ( + Field("Column A", "a"), + Field("Column B", "b"), + Field("Column C", "c"), + Field("Column D", "d"), + ) + data = ( + {"a": 1, "b": 4, "c": 7, "d": "alpha"}, + {"a": 2, "b": 5, "c": 8, "d": "beta"}, + {"a": 3, "b": 6, "c": 9, "d": "gamma"}, + ) + + printer = FoldedTablePrinter(fields=fields, width=25) + # override detection of an interactive session + printer._folding_enabled = True + + with StringIO() as stream: + printer.echo(data, stream) + printed_table = stream.getvalue() + + # fmt: off + assert printed_table == ( + "╒══════════╤══════════╕\n" + "│ Column A ╎ Column C │\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" + "│ Column B ╎ Column D │\n" + "╞══════════╪══════════╡\n" + "│ 1 ╎ 7 │\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" + "│ 4 ╎ alpha │\n" + "├──────────┼──────────┤\n" + "│ 2 ╎ 8 │\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" + "│ 5 ╎ beta │\n" + "├──────────┼──────────┤\n" + "│ 3 ╎ 9 │\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" + "│ 6 ╎ gamma │\n" + "└──────────┴──────────┘\n" + ) + # fmt: on + + +def test_folded_table_printer_can_fold_in_half_unevenly(): + fields = ( + Field("Column A", "a"), + Field("Column B", "b"), + Field("Column C", "c"), + ) + data = ( + {"a": 1, "b": 4, "c": 7, "d": "alpha"}, + {"a": 2, "b": 5, "c": 8, "d": "beta"}, + {"a": 3, "b": 6, "c": 9, "d": "gamma"}, + ) + + printer = FoldedTablePrinter(fields=fields, width=25) + # override detection of an interactive session + printer._folding_enabled = True + + with StringIO() as stream: + printer.echo(data, stream) + printed_table = stream.getvalue() + + # fmt: off + assert printed_table == ( + "╒══════════╤══════════╕\n" + "│ Column A ╎ Column C │\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" + "│ Column B ╎ │\n" + "╞══════════╪══════════╡\n" + "│ 1 ╎ 7 │\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" + "│ 4 ╎ │\n" + "├──────────┼──────────┤\n" + "│ 2 ╎ 8 │\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" + "│ 5 ╎ │\n" + "├──────────┼──────────┤\n" + "│ 3 ╎ 9 │\n" + "├─ ─ ─ ─ ─┼─ ─ ─ ─ ─┤\n" + "│ 6 ╎ │\n" + "└──────────┴──────────┘\n" + ) + # fmt: on + + +def test_row_folding_no_remainder(): + six_items = Row((("1", "2", "3", "4", "5", "6"),)) + + # fold by 2 or "in half" + fold2 = six_items.fold(2) + assert len(fold2.grid) == 2 + assert fold2.grid == ( + ("1", "3", "5"), # odds + ("2", "4", "6"), # evens + ) + + # fold by 3 or "in thirds" + fold3 = six_items.fold(3) + assert len(fold3.grid) == 3 + assert fold3.grid == ( + ("1", "4"), + ("2", "5"), + ("3", "6"), + ) + + # fold by N where N is the number of columns + fold6 = six_items.fold(6) + assert len(fold6.grid) == 6 + assert fold6.grid == ( + ("1",), + ("2",), + ("3",), + ("4",), + ("5",), + ("6",), + ) + + +def test_row_folding_with_remainder(): + five_items = Row( + ( + ( + "1", + "2", + "3", + "4", + "5", + ), + ) + ) + + # fold by 2 or "in half" + fold2 = five_items.fold(2) + assert len(fold2.grid) == 2 + assert fold2.grid == ( + ("1", "3", "5"), # odds + ( + "2", + "4", + ), # evens + ) + + # fold by 3 or "in thirds" + fold3 = five_items.fold(3) + assert len(fold3.grid) == 3 + assert fold3.grid == ( + ("1", "4"), + ("2", "5"), + ("3",), + )