PHPackages                             sugarcraft/sugar-table - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. sugarcraft/sugar-table

ActiveLibrary[Utility &amp; Helpers](/categories/utility)

sugarcraft/sugar-table
======================

PHP port of Evertras/bubble-table — customizable interactive table component for terminal UIs. Supports column definitions with keys/titles/widths, rows as key-value maps, styled cells, selection, pagination, sorting, filtering, frozen columns, horizontal scrolling, zebra striping, and ANSI border styling.

02161PHP

Since Jul 1Pushed 3w agoCompare

[ Source](https://github.com/sugarcraft/sugar-table)[ Packagist](https://packagist.org/packages/sugarcraft/sugar-table)[ RSS](/packages/sugarcraft-sugar-table/feed)WikiDiscussions master Synced 3w ago

READMEChangelogDependenciesVersions (1)Used By (1)

[![sugar-table](.assets/icon.png)](.assets/icon.png)

[![CI](https://github.com/detain/sugarcraft/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/detain/sugarcraft/actions/workflows/ci.yml)[![codecov](https://camo.githubusercontent.com/e7a0aa291595aa6ac7b33624f8a99b6ce4990c7147ac05174997433092b0b984/68747470733a2f2f636f6465636f762e696f2f67682f64657461696e2f737567617263726166742f6272616e63682f6d61737465722f67726170682f62616467652e7376673f666c61673d73756761722d7461626c65)](https://app.codecov.io/gh/detain/sugarcraft?flags%5B0%5D=sugar-table)[![Packagist Version](https://camo.githubusercontent.com/398983fc73221c89db556885ad4fcc8b6409826cc0a001702af6c53a2c6dc421/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f7375676172636f72652f73756761722d7461626c653f6c6162656c3d7061636b6167697374)](https://packagist.org/packages/sugarcore/sugar-table)[![License](https://camo.githubusercontent.com/7013272bd27ece47364536a221edb554cd69683b68a46fc0ee96881174c4214c/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c6963656e73652d4d49542d626c75652e737667)](LICENSE)[![PHP](https://camo.githubusercontent.com/e78ffc83837c0d12647811a7fd1910c3cbeae04988de94bb4fd5b67e0874696a/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d254532253839254135382e312d3838393262662e737667)](https://www.php.net/)

SugarTable
==========

[](#sugartable)

PHP port of [Evertras/bubble-table](https://github.com/Evertras/bubble-table) — customizable interactive table component for terminal UIs.

Features
--------

[](#features)

- **Column definitions**: unique key, title, width (fixed or flexible), optional style
- **Row data**: key-value map (`RowData`), arbitrary values rendered via `fmt.Sprintf("%v")`
- **Styled cells**: `StyledCell` wraps value + ANSI style, overrides row/column/base styles
- **Row styles**: zebra striping, bold rows, per-row ANSI styling
- **Selection**: single-row cursor, up/down navigation
- **Pagination**: page size, page navigation, auto footer
- **Sorting**: asc/desc, multi-column sort, numeric + string sort
- **Filtering**: filter by column text
- **Frozen columns**: pin columns from the left
- **Horizontal scroll**: max width with overflow, frozen columns stay visible
- **Missing data indicator**: configurable placeholder for absent cells
- **Border styling**: `withBorder(Border $border)` — consume any `SugarCraft\Sprinkles\Border` family (normal/rounded/thick/double/block/ascii/hidden/markdownBorder) + `withBorderStyle(string $ansiStyle)` for ANSI color/styling on default border
- **Borderless mode**: `withBorderless()` — render NO outer box (no top/bottom border rows, no left/right border columns; a single space separates columns) so the table composes inside another bordered shell (e.g. a sugar-boxer content box) without a double border. A selected row's reverse-video highlight stays continuous across the column gaps
- **Width-exact rendering**: `withWidth(int $cols)` — pin the total render width; `ColumnWidth::Flex` columns fill exactly the room left after Fixed/Percent columns so every line is exactly `$cols` cells (deterministic for composition). Default `cellPadding` is 0 (flush)
- **Viewport virtualization**: render only visible rows via `withViewportHeight()` + `withScrollY()`
- **Column width modes**: `ColumnWidth` enum — Fixed, Percent, Dynamic, Content, Flex
- **Cell text wrapping**: `WrapMode` enum — None, WordWrap, Character
- **Multi-line row rendering**: `withMultilineMode(bool $multiline)` — when enabled, rows expand to the maximum height of any cell; when disabled (default), cells are clamped to one line (backward compatible)

Install
-------

[](#install)

```
composer require sugarcraft/sugar-table
```

Quick Start
-----------

[](#quick-start)

```
use SugarCraft\Table\{Column, Row, RowData, Table};

$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20),
    Column::new('city', 'City',  15),
])->withRows([
    Row::new(RowData::from(['id' => '1', 'name' => 'Alice',   'city' => 'NYC'])),
    Row::new(RowData::from(['id' => '2', 'name' => 'Bob',     'city' => 'LA'])),
    Row::new(RowData::from(['id' => '3', 'name' => 'Carol',   'city' => 'CHI'])),
]);

echo $t->View();
```

Columns
-------

[](#columns)

```
Column::new($key, $title, $width)       // key, display title, fixed width
    ->withFlexibleWidth($flex)           // flexible width share
    ->withMaxWidth($max)                // horizontal scroll cap
    ->withStyle('1;34')                  // ANSI SGR style
    ->withFilterable()                   // enable built-in filter
    ->withAlignLeft()                    // left-align (default is right)
    ->withColumnWidth($mode, $value)    // ColumnWidth::Fixed|Percent|Dynamic|Content
    ->withWrapMode(WrapMode::None)       // WrapMode::None|WordWrap|Character
```

### ColumnWidth Enum

[](#columnwidth-enum)

`ColumnWidth` specifies how a column's width is computed at render time. The table computes actual widths in `computeColumnWidths($tableWidth)` and uses them consistently throughout rendering (header, data cells, separators).

CaseDescription`Fixed`Fixed character count (uses `Column.width`)`Percent`Percentage of total table width (uses `Column.percentValue`, 0–100)`Dynamic`Min-width from content, flex share of remaining space`Content`Exactly fit content (min 1 char)```
use SugarCraft\Table\ColumnWidth;

// Fixed 10-character column
$col = Column::new('id', 'ID', 10)
    ->withColumnWidth(ColumnWidth::Fixed, 0);

// 25% of table width
$col = Column::new('name', 'Name', 20)
    ->withColumnWidth(ColumnWidth::Percent, 25.0);

// Dynamic (content width or flex share, whichever is larger)
$col = Column::new('city', 'City', 15)
    ->withColumnWidth(ColumnWidth::Dynamic, 0);

// Content-based (exact fit to widest cell)
$col = Column::new('email', 'Email', 30)
    ->withColumnWidth(ColumnWidth::Content, 0);
```

**Dynamic + Content example** — auto-size two columns while a third takes 25%:

```
$t = Table::fromColumns([
    Column::new('id',   'ID',     5)
        ->withColumnWidth(ColumnWidth::Fixed, 0),
    Column::new('name', 'Name',  20)
        ->withColumnWidth(ColumnWidth::Dynamic, 0),   // auto-size to content
    Column::new('note', 'Note',  10)
        ->withColumnWidth(ColumnWidth::Content, 0),  // exact content fit
    Column::new('pct',  'Pct',    0)
        ->withColumnWidth(ColumnWidth::Percent, 25.0), // always 25% of table
])->withRows([...]);

echo $t->View();  // columns 1+2 sized by content; column 3 fills remaining 75%
```

### WrapMode Enum

[](#wrapmode-enum)

`WrapMode` controls how cell text is wrapped:

CaseDescription`None`Truncate at column width`WordWrap`Break at word boundaries`Character`Break at any character, no padding on last line```
use SugarCraft\Table\{Column, WrapMode};

$col = Column::new('desc', 'Description', 20)
    ->withWrapMode(WrapMode::WordWrap);  // or Character or None
```

Rows
----

[](#rows)

```
Row::new($rowData)
    ->withStyle('1')                    // bold entire row
    ->withZebra()                       // alternating style

// Styled cell (overrides row+column style)
StyledCell::new('value', '31;1')        // red bold cell
```

Navigation &amp; Sorting
------------------------

[](#navigation--sorting)

```
$t = $t->SortBy('name', ascending: true);
$t = $t->Filter('name', 'alice');       // filter column by text
$t = $t->SelectNext();                  // move cursor down
$t = $t->SelectPrevious();              // move cursor up
$t = $t->CurrentRow();                  // get selected RowData
```

Keyboard Navigation
-------------------

[](#keyboard-navigation)

Scroll the table vertically using keyboard input via `scrollYForKey()` and `handleKey()`:

```
use SugarCraft\Table\Table;

// Key constants for navigation
$t = $t->handleKey(Table::KEY_ARROW_UP);    // scroll up one row
$t = $t->handleKey(Table::KEY_ARROW_DOWN);  // scroll down one row
$t = $t->handleKey(Table::KEY_PAGE_UP);     // scroll up one viewport
$t = $t->handleKey(Table::KEY_PAGE_DOWN);   // scroll down one viewport
$t = $t->handleKey(Table::KEY_HOME);        // scroll to top
$t = $t->handleKey(Table::KEY_END);         // scroll to bottom
```

### Key Constants

[](#key-constants)

ConstantDescription`KEY_ARROW_UP`Scroll up by 1 row`KEY_ARROW_DOWN`Scroll down by 1 row`KEY_PAGE_UP`Scroll up by one viewport height`KEY_PAGE_DOWN`Scroll down by one viewport height`KEY_HOME`Scroll to first row`KEY_END`Scroll to last row### scrollYForKey() — Raw Scroll Calculation

[](#scrollyforkey--raw-scroll-calculation)

Returns the new `scrollY` value for a key without modifying the table:

```
$newScrollY = $t->scrollYForKey(Table::KEY_ARROW_UP);
$t = $t->withScrollY($newScrollY);
```

This is useful when you need the raw integer value for your own integration logic.

### handleKey() — Convenience Wrapper

[](#handlekey--convenience-wrapper)

Returns a new Table with `scrollY` already adjusted:

```
$t = $t->handleKey($keyFromInputHandler);
```

This combines `scrollYForKey()` + `withScrollY()` in one call.

### Integration Example

[](#integration-example)

```
use SugarCraft\Table\Table;

// Create table with viewport virtualization enabled
$t = Table::fromColumns([...])
    ->withRows([...])
    ->withViewportHeight(15);

// Simulate keyboard input
$key = 'arrowDown';  // from your input library (e.g., candy-pty)
$t = $t->handleKey($key);

// Or use the constants
$t = $t->handleKey(Table::KEY_PAGE_DOWN);
```

### How It Works

[](#how-it-works)

- **Key mapping**: `scrollYForKey()` uses a `match` expression to map key names to scroll deltas
- **Bounds clamping**: Scroll values are clamped to `0` at the top and `maxScrollY()` at the bottom
- **maxScrollY()**: Returns `max(0, totalFilteredRows - viewportHeight)` when viewport is active; `0` otherwise
- **No-op for unknown keys**: Unrecognized keys return the current `scrollY` unchanged
- **Requires viewport**: Keyboard scrolling only works when `withViewportHeight()` is set

```
// Combined: keyboard scroll + cursor selection
$t = $t->withScrollY($t->scrollYForKey($key))  // update scroll
       ->SelectNext();                         // move selection
```

Global Search
-------------

[](#global-search)

Search across all columns simultaneously with `search()`:

```
$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20),
    Column::new('city', 'City',  15),
])->withRows([
    Row::new(RowData::from(['id' => '1', 'name' => 'Alice',   'city' => 'NYC'])),
    Row::new(RowData::from(['id' => '2', 'name' => 'Bob',     'city' => 'LA'])),
    Row::new(RowData::from(['id' => '3', 'name' => 'Carol',   'city' => 'CHI'])),
]);

$t = $t->search('alice');    // finds row with "Alice" (case-insensitive)
$t = $t->search('ny');       // finds row with "NYC"
$t = $t->search('');         // clears search (shows all rows)
$t = $t->ClearSearch();     // alias for search('')
```

### How It Works

[](#how-it-works-1)

- **Case-insensitive**: `search('ALICE')` matches `"alice"`, `"Alice"`, `"ALICE"`
- **OR logic**: A row matches if **any** column contains the search text
- **Combines with filters**: Global search is ANDed with column filters — a row must match both
- **Resets selection**: `search()` automatically resets `selectedIndex` to 0

```
// Combined: Filter by name column AND search all columns for "ny"
$t = $t->Filter('name', 'alice')  // name must contain "alice"
        ->search('ny');           // some column must contain "ny"

// Row 1 (Alice, NYC): passes Filter, passes search ✅
// Row 2 (Bob, LA):    fails Filter ❌
// Row 3 (Carol, CHI): fails both ❌
```

### Interaction with Filter()

[](#interaction-with-filter)

MethodScopeLogic`Filter()`Single columnAND (row must match ALL column filters)`search()`All columnsOR (row matches if ANY column contains text)Both can be active simultaneously:

```
$t = $t->Filter('city', 'ny')     // city must contain "ny"
        ->search('li');           // some column must contain "li"

// Row 1 (Alice, NYC): city matches "ny", search matches "li" in "Alice" ✅
// Row 2 (Bob, LA):    city matches "la"? No ❌
// Row 3 (Carol, CHI): city fails, search matches "li" in "Carol" — fails Filter ❌
```

Pagination
----------

[](#pagination)

```
$t = $t->withPageSize(25)               // 25 rows per page
    ->withPage(2);                      // show page 2
echo $t->PageFooter();                  // 'Page 2 of 4' (i18n-aware)
```

### Footer Types

[](#footer-types)

Control what the footer displays using the `FooterType` enum:

```
use SugarCraft\Table\{Table, FooterType};

$t = Table::fromColumns([...])
    ->withRows([...])
    ->withPageSize(25)
    ->withFooterType(FooterType::Page);  // Default: "Page 2 of 4"
```

CaseFooter DisplayMethod`Page``Page N of M``PageFooter()``Rows``Showing X to Y of Z rows``RowsFooter()``Both``Page N of MShowing X to Y of Z rows````
// Show only row count footer
$t = $t->withFooterType(FooterType::Rows);
echo $t->View();  // Footer: "Showing 1 to 25 of 100 rows"

// Show both page and row count
$t = $t->withFooterType(FooterType::Both);
echo $t->View();  // Footer: "Page 2 of 4  |  Showing 26 to 50 of 100 rows"
```

The row count footer uses the `showing_rows` i18n key and updates automatically based on the current page and any active filters/searches.

Viewport Virtualization
-----------------------

[](#viewport-virtualization)

Render only a visible slice of rows for large datasets:

```
$t = Table::fromColumns([...])
    ->withRows($bigDataset)
    ->withViewportHeight(15)           // show 15 rows at a time
    ->withScrollY(30);                 // start at row 30

echo $t->View();                        // renders rows 30-44

$currentScroll = $t->scrollY();        // get current scroll offset
```

The table automatically slices the visible row range from the filtered+sorted view. `scrollY()` returns the current vertical scroll offset.

Frozen Columns
--------------

[](#frozen-columns)

Pin columns from the left so they remain visible when scrolling horizontally:

```
$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20),
    Column::new('city', 'City',  15),
    Column::new('note', 'Note',  40),
])->withRows([...])
  ->withFrozenCols([0, 1]);           // freeze ID and Name columns

echo $t->View();                       // ID and Name always visible
```

### How It Works

[](#how-it-works-2)

- **Frozen columns** (specified by index) are always rendered, regardless of scroll position
- **Non-frozen columns** scroll horizontally: they become visible starting at index `count(frozenCols) + scrollX`
- Use `withScrollX($offset)` to scroll the non-frozen columns

### Frozen Columns with Horizontal Scroll

[](#frozen-columns-with-horizontal-scroll)

Combine frozen columns with `scrollX` for a spreadsheet-like experience:

```
$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20),
    Column::new('city', 'City',  15),
    Column::new('note', 'Note',  40),
    Column::new('tags', 'Tags',  20),
])->withRows([...])
  ->withFrozenCols([0, 1])            // freeze ID and Name
  ->withScrollX(2);                   // skip 2 non-frozen columns (City, Note)

echo $t->View();
// Visible columns: ID, Name, Tags
// City and Note columns are hidden (scrolled out of view)
```

### Visibility Logic

[](#visibility-logic)

A column is visible when:

1. Its index is in the `frozenCols` array (always visible), OR
2. Its index &gt;= `count(frozenCols) + scrollX` (in the scrollable region)

```
// Given: frozenCols = [0, 2], scrollX = 1
// Non-frozen columns start at index: 2 + 1 = 3
// Column 0 (frozen):  visible
// Column 1 (index 1): NOT visible (1 < 3)
// Column 2 (frozen):  visible
// Column 3 (index 3): visible (3 >= 3)
// Column 4 (index 4): visible (4 >= 3)
```

Horizontal Scroll
-----------------

[](#horizontal-scroll)

Scroll horizontally through columns that exceed the table width:

```
$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20),
    Column::new('city', 'City',  15),
    Column::new('note', 'Note',  40),
])->withRows([...])
  ->withScrollX(2);                 // skip first 2 non-frozen columns

echo $t->View();
// Columns 0 and 1 are hidden from view
```

### How It Works

[](#how-it-works-3)

- **scrollX** skips `$offset` non-frozen columns from the left of the scrollable region
- **Frozen columns** (if any) are always visible regardless of scrollX
- **Negative values** are clamped to 0 automatically
- **Excessive scroll** values are tolerated — extra columns simply don't render

### Interaction with Frozen Columns

[](#interaction-with-frozen-columns)

When combining `withScrollX()` with `withFrozenCols()`:

```
$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20),
    Column::new('city', 'City',  15),
    Column::new('note', 'Note',  40),
    Column::new('tags', 'Tags',  20),
])->withRows([...])
  ->withFrozenCols([0])             // freeze ID column
  ->withScrollX(2);                 // skip 2 non-frozen columns

echo $t->View();
// Column 0 (ID, frozen):  visible
// Column 1 (Name):        skipped (index 1 < 1 + 2 = 3)
// Column 2 (City):        skipped (index 2 < 3)
// Column 3 (Note):        visible (index 3 >= 3)
// Column 4 (Tags):        visible (index 4 >= 3)
```

The visibility formula: a column is visible when its index is in `frozenCols` OR `index >= count(frozenCols) + scrollX`.

Column Visibility Toggle
------------------------

[](#column-visibility-toggle)

Hide columns by index without removing them from the table:

```
use SugarCraft\Table\Table;

$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20),
    Column::new('city', 'City',  15),
    Column::new('note', 'Note',  40),   // this column can be hidden
])->withRows([...])
  ->withHiddenCols([3]);                 // hide the Note column (index 3)

echo $t->View();
// Only ID, Name, City columns are rendered; Note column is hidden
```

### How It Works

[](#how-it-works-4)

- **Hidden columns are excluded from rendering** but still exist in the table
- **Data, filters, and sorting still work** on hidden columns — you can filter by a hidden column's data
- **Useful for optional columns** that can be toggled visible/invisible via UI
- **Column indices refer to the original column order** — not affected by scroll position

```
// Hide multiple columns
$t = $t->withHiddenCols([2, 3]);         // hide columns at indices 2 and 3

// Show all columns (empty array)
$t = $t->withHiddenCols([]);             // no columns hidden

// Combine with frozen columns - hide a frozen column
$t = $t->withFrozenCols([0])
        ->withHiddenCols([0]);           // freeze and hide are independent
```

### Interaction with Frozen Columns and Scroll

[](#interaction-with-frozen-columns-and-scroll)

Hidden columns are **never rendered**, regardless of frozen status or scroll position:

```
// Given: frozenCols = [0], hiddenCols = [2], scrollX = 0
// Column 0 (frozen):   visible
// Column 1 (index 1):   visible (>= 1 + 0 = 1)
// Column 2 (index 2):   NEVER visible (in hiddenCols)
// Column 3 (index 3):   visible (>= 1 + 0 = 1)
```

Column Width Computation
------------------------

[](#column-width-computation)

Compute actual column widths from `ColumnWidth` enum values:

```
$widths = $t->computeColumnWidths(80);
// Returns: [5, 20, 15, 40]  (colIndex => character width)

foreach ($t->Columns() as $i => $col) {
    printf("%s => %d chars\n", $col->title, $widths[$i]);
}
```

Uses multi-pass computation:

1. **Pass 1**: Collect Fixed/Percent widths, count Dynamic/Content columns
2. **Pass 2**: Distribute remaining space among Dynamic/Content columns
3. **Dynamic**: `max(contentWidth, flexShare)`
4. **Content**: exact content width (min 1) All translatable strings live in `lang/en.php` under the `'table'` namespace.

**Available keys** (`lang/en.php`):

KeyDefault stringParameters`page_of``Page {page} of {total}``{page}`, `{total}``no_data``No data`—`showing_rows``Showing {from} to {to} of {total} rows``{from}`, `{to}`, `{total}``sort``Sort`—`filter``Filter`—To add a locale, copy `lang/en.php` to `lang/.php` and translate the values. The lookup chain follows `SugarCraft\Core\I18n\T`: exact locale → base language → `en` → raw key.

**Adding new translatable strings:**

```
// In any source file:
use SugarCraft\Table\Lang;

$label = Lang::t('sort');                  // 'Sort'
$pager = Lang::t('page_of', ['page' => 2, 'total' => 4]); // 'Page 2 of 4'
```

Border Styling
--------------

[](#border-styling)

Customize the table border using the `SugarCraft\Sprinkles\Border` family:

```
use SugarCraft\Table\Table;
use SugarCraft\Sprinkles\Border;

$t = Table::fromColumns([...])
    ->withRows([...])
    ->withBorder(Border::rounded());   // ─ │ ╭ ╮ ╰ ╯
    // ->withBorder(Border::thick())  // ━ ┃ ┏ ┓ ┗ ┛
    // ->withBorder(Border::double())  // ═ ║ ╔ ╗ ╚ ╝
    // ->withBorder(Border::ascii())  // - | + + + +
    // ->withBorder(Border::hidden())  // all spaces
    // ->withBorder(Border::markdownBorder())  // | - | ...

// Or style the default border with ANSI colors:
$t = $t->withBorderStyle('1;32');       // bold green
```

Available border factories: `Border::normal()`, `rounded()`, `thick()`, `double()`, `block()`, `ascii()`, `hidden()`, `markdownBorder()`.

Cell Padding Control
--------------------

[](#cell-padding-control)

Add inner spacing inside each cell for better visual breathing room:

```
use SugarCraft\Table\Table;

$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20),
    Column::new('city', 'City',  15),
])->withRows([
    Row::new(RowData::from(['id' => '1', 'name' => 'Alice',   'city' => 'NYC'])),
    Row::new(RowData::from(['id' => '2', 'name' => 'Bob',     'city' => 'LA'])),
    Row::new(RowData::from(['id' => '3', 'name' => 'Carol',   'city' => 'CHI'])),
])->withCellPadding(1);                // 1 space on each side
    // ->withCellPadding(2);            // 2 spaces for more breathing room
    // ->withCellPadding(0);            // no padding (flush with borders)

echo $t->View();
// With padding 1: "│  1  │  Alice              │  NYC   │"
// Without padding:  "│ 1 │ Alice │ NYC │"
```

### How It Works

[](#how-it-works-5)

- **Padding** adds whitespace on the left and right sides of each cell's content
- **Does not affect column width calculations** — the column width remains the same; padding is subtracted from the effective content width
- **Applied to header and data cells** — both headers and row data benefit from consistent inner padding
- **Combined with other features** — works with frozen columns, horizontal scroll, multiline mode, and row expansion

```
// Combine padding with other features
$t = $t->withCellPadding(2)
        ->withFrozenCols([0])
        ->withMultilineMode(true);
```

Multi-line Rows
---------------

[](#multi-line-rows)

Enable multi-line row rendering to display tall cell content that wraps within its column width:

```
use SugarCraft\Table\{Table, Column, Row, RowData, WrapMode};

$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20)->withWrapMode(WrapMode::WordWrap),
    Column::new('city', 'City',  15)->withWrapMode(WrapMode::Character),
])
    ->withRows([
        Row::new(RowData::from([
            'id'   => '1',
            'name' => 'Alice Johnson',
            'city' => 'New York City',
        ])),
        Row::new(RowData::from([
            'id'   => '2',
            'name' => 'Bob Smith with a very long name',
            'city' => 'Los\nAngeles',  // embedded newline
        ])),
    ])
    ->withMultilineMode(true);          // rows expand to max cell height
    // ->withMultilineMode(false);     // default, clamps to single line

echo $t->View();
```

### How It Works

[](#how-it-works-6)

When `withMultilineMode(true)` is enabled:

- **Row height** equals the maximum number of lines across all visible cells after text wrapping
- **Short cells** are vertically padded with empty space to match row height
- **Borders** span the full row height on each line
- **Cell wrapping** respects the column's `WrapMode`: `WordWrap` breaks at word boundaries, `Character` breaks at any character, `None` truncates

When disabled (the default), cells are clamped to one line for backward compatibility.

### Interaction with WrapMode

[](#interaction-with-wrapmode)

Multiline mode requires `WrapMode::WordWrap` or `WrapMode::Character` on columns to produce multiple lines. Without wrapping enabled, cells remain single-line even in multiline mode.

```
// Word wrap example — breaks at word boundaries within 8 characters
Column::new('bio', 'Bio', 8)->withWrapMode(WrapMode::WordWrap);
// "one two three four" → "one two", "three", "four" (3 lines)

// Character wrap example — breaks at any character within 5 characters
Column::new('code', 'Code', 5)->withWrapMode(WrapMode::Character);
// "ABCDEFGHIJ" → "ABCDE", "FGHIJ" (2 lines)
```

Row Expansion
-------------

[](#row-expansion)

Expand rows to display full content without column width truncation:

```
use SugarCraft\Table\{Table, Column, Row, RowData};

$t = Table::fromColumns([
    Column::new('id',   'ID',     5),
    Column::new('name', 'Name',  20),
    Column::new('desc', 'Desc',  15),   // truncated at 15 chars normally
])
    ->withRows([
        Row::new(RowData::from([
            'id'   => '1',
            'name' => 'Alice',
            'desc' => 'This is a very long description that would normally be truncated',
        ])),
        Row::new(RowData::from([
            'id'   => '2',
            'name' => 'Bob',
            'desc' => 'Short',
        ])),
    ])
    ->withExpandedRows([0]);              // expand row 0 (Alice)

echo $t->View();
// Row 0 (Alice): full description visible — not truncated to 15 chars
// Row 1 (Bob):   normal truncation applies
```

### Toggle Expansion

[](#toggle-expansion)

Use `toggleExpanded()` to interactively expand/collapse rows:

```
$t = $t->toggleExpanded(0);   // expand row 0 (Alice)
$t = $t->toggleExpanded(0);   // collapse row 0 (back to truncated)
$t = $t->toggleExpanded(1);   // expand row 1 (Bob)
```

### Check Expansion State

[](#check-expansion-state)

Use `isExpanded()` to query whether a row is currently expanded:

```
$t = $t->withExpandedRows([0]);
$t->isExpanded(0);   // true — row 0 is expanded
$t->isExpanded(1);   // false — row 1 is not expanded
```

### How It Works

[](#how-it-works-7)

- **Row identity**: Expanded rows are tracked by object identity (`Row` instance), not by index — this is stable across page navigation
- **Pagination**: All expansion methods (`withExpandedRows`, `toggleExpanded`, `isExpanded`) use page-relative indices via `pagedRows()`
- **Multiline mode**: In `multilineMode=true`, expanded rows also bypass column width constraints so all wrapped content is visible
- **No content filtering**: Expansion does not filter or transform row data; it only controls rendering behavior (truncation vs full display)
- **Fail fast**: Invalid indices throw `OutOfBoundsException`

```
// Combined with pagination — page 1
$t = $t->withPageSize(10)->withPage(1)->withExpandedRows([0]);

// Row index 0 refers to the first row on page 1, not the first row overall
$t->isExpanded(0);   // true — row 0 on page 1 is expanded

// Row index 5 on page 2 might be a different Row object
$t = $t->withPage(2);
$t->isExpanded(5);   // depends on whether that row was expanded on page 2
```

Shared foundations
------------------

[](#shared-foundations)

sugar-table adopts `candy-buffer` for all buffer-based rendering. The table's internal `Buffer` instance is constructed once per `View()` call and passed through all layout methods.

### styleFunc signature

[](#stylefunc-signature)

`Table::withStyleFunc()` accepts a callable with the signature:

```
function(int $row, int $col, string $value): Style|string
```

- **Returns `Style`** — new preferred style (PHP 8.3+ typed return, immutable)
- **Returns `string`** — legacy ANSI SGR string, automatically wrapped via `Style::fromAnsiString()` for backward compatibility

Implementations should return `Style` when possible; the wrapper path is identical to the old behavior but adds one allocation.

Snapshot tests
--------------

[](#snapshot-tests)

Render output is covered by golden-file snapshot tests. Fixture files live in `tests/fixtures/` with a `.golden` extension and are compared against actual ANSI byte output via `SugarCraft\Testing\Snapshot\Assertions::assertGoldenAnsi()`. To re-record fixtures after intentional output changes:

```
UPDATE_GOLDENS=1 vendor/bin/phpunit
```

License
-------

[](#license)

[MIT](LICENSE)

###  Health Score

25

—

LowBetter than 35% of packages

Maintenance62

Regular maintenance activity

Popularity16

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity11

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

### Community

Maintainers

![](https://www.gravatar.com/avatar/b1036e0717211b8030b83cbe729e8ba6ba442fdbd5285fb97a39d7dcfe339342?d=identicon)[detain](/maintainers/detain)

---

Top Contributors

[![detain](https://avatars.githubusercontent.com/u/1364504?v=4)](https://github.com/detain "detain (104 commits)")

### Embed Badge

![Health badge](/badges/sugarcraft-sugar-table/health.svg)

```
[![Health](https://phpackages.com/badges/sugarcraft-sugar-table/health.svg)](https://phpackages.com/packages/sugarcraft-sugar-table)
```

###  Alternatives

[cerbero/laravel-dto

Data Transfer Object (DTO) for Laravel

5813.7k](/packages/cerbero-laravel-dto)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
