Skip to content

Commit b1ed84b

Browse files
authored
Merge pull request #26 from kdkavanagh/column-fit
Add auto column width mode
2 parents 71ed80c + 14c8a26 commit b1ed84b

7 files changed

Lines changed: 1014 additions & 2 deletions

File tree

src/dt_browser/browser.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,7 @@ class DtBrowser(Widget): # pylint: disable=too-many-public-methods,too-many-ins
329329
Binding("G", "last_row", "Jump to bottom", show=False),
330330
Binding("C", "show_colors", "Colors...", key_display="shift+C"),
331331
("ctrl+s", "show_save", "Save dataframe as..."),
332+
("w", "toggle_auto_width", "Auto Width"),
332333
]
333334

334335
color_by: reactive[tuple[str, ...]] = reactive(tuple(), init=False)
@@ -571,6 +572,11 @@ def action_toggle_bookmark(self):
571572
async def action_toggle_row_detail(self):
572573
self.show_row_detail = not self.show_row_detail
573574

575+
def action_toggle_auto_width(self) -> None:
576+
table = self.query_one("#main_table", CustomTable)
577+
table.auto_width = not table.auto_width
578+
self.notify("Auto column width: " + ("ON" if table.auto_width else "OFF"), timeout=2)
579+
574580
async def action_last_row(self):
575581
table = self.query_one("#main_table", CustomTable)
576582
coord = table.cursor_coordinate

src/dt_browser/custom_table.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,7 @@ def control(self) -> CustomTable:
192192
COMPONENT_CLASSES: ClassVar[set[str]] = {"datatable--header", "datatable--cursor", "datatable--even-row"}
193193

194194
cursor_coordinate: Reactive[Coordinate] = Reactive(Coordinate(0, 0), repaint=False)
195+
auto_width: Reactive[bool] = Reactive(False, repaint=False)
195196

196197
def __init__(
197198
self,
@@ -221,6 +222,8 @@ def __init__(
221222

222223
self._render_header_and_table: tuple[Strip, pl.DataFrame] | None = None
223224
self._dirty = True
225+
self._full_widths: dict[str, int] = {}
226+
self._auto_width_visible_range: tuple[int, int] | None = None
224227

225228
self.set_dt(dt, metadata_dt)
226229

@@ -268,6 +271,8 @@ def set_dt(self, dt: pl.DataFrame, metadata_dt: pl.DataFrame):
268271
self._dt = dt
269272
self._metadata_dt = metadata_dt
270273
self._set_widths({x: max(len(x), self._measure(self._dt[x])) for x in self._dt.columns})
274+
self._full_widths = self._widths.copy()
275+
self._auto_width_visible_range = None
271276
self._render_header_and_table = None
272277
self._formatters = {x: self._build_cast_expr(x, padding=self._widths[x]) for x in self._dt.columns}
273278
self._build_header_contents()
@@ -282,6 +287,24 @@ def _set_widths(self, widths: dict[str, int]):
282287
for k, v in zip(self._dt.columns, accumulate(x + COL_PADDING for x in self._widths.values()), strict=False)
283288
}
284289

290+
def _compute_auto_widths(self, scroll_y: int, dt_height: int) -> dict[str, int]:
291+
"""Compute column widths based only on currently visible rows."""
292+
widths = {}
293+
for col in self._dt.columns:
294+
visible_slice = self._dt[col].slice(scroll_y, dt_height)
295+
data_width = self._measure(visible_slice) if len(visible_slice) > 0 else 0
296+
widths[col] = max(len(col), data_width)
297+
return widths
298+
299+
def watch_auto_width(self, value: bool) -> None:
300+
if not value:
301+
self._set_widths(self._full_widths)
302+
self._formatters = {x: self._build_cast_expr(x, padding=self._widths[x]) for x in self._dt.columns}
303+
self._build_header_contents()
304+
self._auto_width_visible_range = None
305+
self._render_header_and_table = None
306+
self.refresh(repaint=True)
307+
285308
def render_line(self, y, *_):
286309
if y >= len(self._lines):
287310
pad = " " * (self.content_region.width)
@@ -357,6 +380,7 @@ def on_resize(self, event: events.Resize):
357380

358381
def _ensure_cursor(self, allow_refresh: bool = True):
359382
self._render_header_and_table = None
383+
self._auto_width_visible_range = None
360384

361385
max_idx = self.cursor_coordinate.column
362386
while not self._is_col_visible(max_idx) and max_idx > 0:
@@ -496,10 +520,25 @@ def render_header_and_table(self):
496520
self._dirty = True
497521
scroll_x, scroll_y = self.scroll_offset
498522

499-
cols_to_render: list[str] = []
500523
effective_width = self.scrollable_content_region.width
501524
if effective_width <= 2:
502525
return (Strip([]), pl.DataFrame())
526+
527+
dt_height = self.window_region.height - HEADER_HEIGHT
528+
529+
if self.auto_width:
530+
visible_range = (scroll_y, dt_height)
531+
if visible_range != self._auto_width_visible_range:
532+
self._auto_width_visible_range = visible_range
533+
new_widths = self._compute_auto_widths(scroll_y, dt_height)
534+
if new_widths != self._widths:
535+
self._set_widths(new_widths)
536+
self._formatters = {
537+
x: self._build_cast_expr(x, padding=self._widths[x]) for x in self._dt.columns
538+
}
539+
self._build_header_contents()
540+
541+
cols_to_render: list[str] = []
503542
truncate_last: int | None = None
504543
for x in self._dt.columns:
505544
min_offset = self._cum_widths[x] - scroll_x
@@ -518,7 +557,6 @@ def render_header_and_table(self):
518557
if not cols_to_render:
519558
return (Strip([]), pl.DataFrame())
520559

521-
dt_height = self.window_region.height - HEADER_HEIGHT
522560
base_header, header_width = self._build_base_header(cols_to_render)
523561
excess = self.scrollable_content_region.width - header_width
524562
header = Strip(base_header + (self._header_pad * (excess)))
@@ -584,6 +622,7 @@ def build_selector(cols: list[str], needed_padding: int = 0):
584622
)
585623
else:
586624
cursor_col_idx = self.cursor_coordinate.column - self._dt.columns.index(visible_cols[0])
625+
cursor_col_idx = max(0, min(cursor_col_idx, len(visible_cols) - 1))
587626

588627
cols_before_selected: list[str] = visible_cols[0:cursor_col_idx]
589628
sel_col = visible_cols[cursor_col_idx]

tests/__snapshots__/test_auto_width/test_snap_auto_width_off.svg

Lines changed: 182 additions & 0 deletions
Loading

tests/__snapshots__/test_auto_width/test_snap_auto_width_on.svg

Lines changed: 182 additions & 0 deletions
Loading

tests/__snapshots__/test_auto_width/test_snap_auto_width_on_scrolled_bottom.svg

Lines changed: 182 additions & 0 deletions
Loading

tests/__snapshots__/test_auto_width/test_snap_auto_width_toggled_off_after_on.svg

Lines changed: 183 additions & 0 deletions
Loading

tests/test_auto_width.py

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
import polars as pl
2+
from textual.pilot import Pilot
3+
4+
from dt_browser.browser import DtBrowserApp
5+
from dt_browser.custom_table import CustomTable
6+
7+
8+
def _make_varying_width_app(num_rows: int = 50) -> DtBrowserApp:
9+
"""Create an app where row data varies in width so auto-width has a visible effect.
10+
11+
Early rows have short values, later rows have long values.
12+
"""
13+
df = pl.DataFrame(
14+
{
15+
"short_col": [f"s{i}" for i in range(num_rows)],
16+
"growing_col": [f"{'x' * (i + 1)}" for i in range(num_rows)],
17+
"fixed_col": [f"fixed_{i:04d}" for i in range(num_rows)],
18+
}
19+
)
20+
return DtBrowserApp("test", df)
21+
22+
23+
def _make_app_simple(num_rows: int = 20) -> DtBrowserApp:
24+
df = pl.DataFrame(
25+
{
26+
"name": [f"item_{i}" for i in range(num_rows)],
27+
"value": list(range(num_rows)),
28+
"score": [round(i * 1.5, 1) for i in range(num_rows)],
29+
"category": [f"cat_{i % 5}" for i in range(num_rows)],
30+
}
31+
)
32+
return DtBrowserApp("test", df)
33+
34+
35+
async def test_auto_width_toggle():
36+
"""Toggling auto_width on and off updates the reactive property."""
37+
app = _make_varying_width_app()
38+
async with app.run_test(size=(120, 30)) as pilot:
39+
await pilot.pause()
40+
table = app.query_one("#main_table", CustomTable)
41+
42+
assert table.auto_width is False
43+
44+
await pilot.press("w")
45+
await pilot.pause()
46+
assert table.auto_width is True
47+
48+
await pilot.press("w")
49+
await pilot.pause()
50+
assert table.auto_width is False
51+
52+
53+
async def test_auto_width_narrows_columns():
54+
"""When auto_width is on, columns are narrower if visible rows have shorter data."""
55+
app = _make_varying_width_app(num_rows=100)
56+
async with app.run_test(size=(120, 30)) as pilot:
57+
await pilot.pause()
58+
table = app.query_one("#main_table", CustomTable)
59+
60+
# Full widths computed from all 100 rows (growing_col goes up to 100 chars)
61+
full_widths = table._widths.copy()
62+
63+
await pilot.press("w")
64+
await pilot.pause()
65+
66+
# Auto widths should be narrower for growing_col since visible rows are near the top
67+
auto_widths = table._widths.copy()
68+
assert auto_widths["growing_col"] < full_widths["growing_col"], (
69+
f"Expected auto width ({auto_widths['growing_col']}) < full width ({full_widths['growing_col']}) "
70+
f"for growing_col when viewing top rows"
71+
)
72+
73+
74+
async def test_auto_width_updates_on_scroll():
75+
"""Scrolling to rows with wider data increases auto column widths."""
76+
app = _make_varying_width_app(num_rows=100)
77+
async with app.run_test(size=(120, 30)) as pilot:
78+
await pilot.pause()
79+
table = app.query_one("#main_table", CustomTable)
80+
81+
await pilot.press("w")
82+
await pilot.pause()
83+
widths_at_top = table._widths.copy()
84+
85+
# Scroll to the bottom where growing_col values are much wider
86+
await pilot.press("G")
87+
await pilot.pause()
88+
89+
widths_at_bottom = table._widths.copy()
90+
assert widths_at_bottom["growing_col"] > widths_at_top["growing_col"], (
91+
f"Expected wider growing_col at bottom ({widths_at_bottom['growing_col']}) "
92+
f"than at top ({widths_at_top['growing_col']})"
93+
)
94+
95+
96+
async def test_auto_width_restores_full_widths_on_toggle_off():
97+
"""Toggling auto_width off restores the original precomputed widths."""
98+
app = _make_varying_width_app(num_rows=100)
99+
async with app.run_test(size=(120, 30)) as pilot:
100+
await pilot.pause()
101+
table = app.query_one("#main_table", CustomTable)
102+
103+
full_widths = table._widths.copy()
104+
105+
# Toggle on
106+
await pilot.press("w")
107+
await pilot.pause()
108+
assert table._widths != full_widths # auto widths should differ
109+
110+
# Toggle off
111+
await pilot.press("w")
112+
await pilot.pause()
113+
assert table._widths == full_widths, "Widths should be restored to full precomputed values"
114+
115+
116+
async def test_auto_width_respects_column_name_min_width():
117+
"""Auto width never makes a column narrower than its header name."""
118+
app = _make_varying_width_app(num_rows=100)
119+
async with app.run_test(size=(120, 30)) as pilot:
120+
await pilot.pause()
121+
table = app.query_one("#main_table", CustomTable)
122+
123+
await pilot.press("w")
124+
await pilot.pause()
125+
126+
for col in table._dt.columns:
127+
assert table._widths[col] >= len(col), (
128+
f"Column '{col}' width ({table._widths[col]}) is less than header name length ({len(col)})"
129+
)
130+
131+
132+
async def test_auto_width_skips_recompute_when_range_unchanged():
133+
"""Moving cursor within visible area does not trigger width recomputation."""
134+
app = _make_varying_width_app(num_rows=100)
135+
async with app.run_test(size=(120, 30)) as pilot:
136+
await pilot.pause()
137+
table = app.query_one("#main_table", CustomTable)
138+
139+
await pilot.press("w")
140+
await pilot.pause()
141+
widths_before = table._widths.copy()
142+
range_before = table._auto_width_visible_range
143+
144+
# Move cursor within visible area
145+
await pilot.press("down")
146+
await pilot.pause()
147+
148+
assert table._auto_width_visible_range == range_before, "Visible range should not change for in-view cursor move"
149+
assert table._widths == widths_before, "Widths should not change when cursor moves within visible area"
150+
151+
152+
async def test_auto_width_with_resize():
153+
"""Auto width recalculates when the terminal is resized."""
154+
app = _make_varying_width_app(num_rows=100)
155+
async with app.run_test(size=(120, 30)) as pilot:
156+
await pilot.pause()
157+
table = app.query_one("#main_table", CustomTable)
158+
159+
await pilot.press("w")
160+
await pilot.pause()
161+
range_before = table._auto_width_visible_range
162+
163+
# Resize changes dt_height, so auto widths should recompute
164+
await pilot.resize_terminal(120, 20)
165+
await pilot.pause()
166+
167+
# Force a render by accessing the property
168+
_ = table.render_header_and_table
169+
170+
assert table._auto_width_visible_range != range_before, (
171+
"Visible range should change after resize"
172+
)
173+
174+
175+
async def test_auto_width_all_null_columns():
176+
"""Auto width does not crash when all visible columns have null values."""
177+
df = pl.DataFrame(
178+
{
179+
"col_a": [None, None, None, None, None],
180+
"col_b": [None, None, None, None, None],
181+
"col_c": [None, None, None, None, None],
182+
},
183+
schema={"col_a": pl.Utf8, "col_b": pl.Utf8, "col_c": pl.Utf8},
184+
)
185+
app = DtBrowserApp("test", df)
186+
async with app.run_test(size=(120, 30)) as pilot:
187+
await pilot.pause()
188+
table = app.query_one("#main_table", CustomTable)
189+
190+
# Switch to cell cursor mode then enable auto width
191+
await pilot.press("w")
192+
await pilot.pause()
193+
194+
# Should not crash — columns should be at least header-name width
195+
for col in table._dt.columns:
196+
assert table._widths[col] >= len(col)
197+
198+
199+
# --- Snapshot tests ---
200+
201+
202+
def test_snap_auto_width_off(snap_compare):
203+
"""Snapshot with auto width OFF (default)."""
204+
assert snap_compare(_make_varying_width_app(num_rows=100), terminal_size=(120, 30))
205+
206+
207+
def test_snap_auto_width_on(snap_compare):
208+
"""Snapshot with auto width ON — columns should be narrower at top of data."""
209+
assert snap_compare(
210+
_make_varying_width_app(num_rows=100),
211+
press=["w"],
212+
terminal_size=(120, 30),
213+
)
214+
215+
216+
def test_snap_auto_width_on_scrolled_bottom(snap_compare):
217+
"""Snapshot with auto width ON after scrolling to bottom — columns should be wider."""
218+
219+
async def run_before(pilot: Pilot) -> None:
220+
await pilot.press("w")
221+
await pilot.pause()
222+
await pilot.press("G")
223+
await pilot.pause()
224+
225+
assert snap_compare(
226+
_make_varying_width_app(num_rows=100),
227+
run_before=run_before,
228+
terminal_size=(120, 30),
229+
)
230+
231+
232+
def test_snap_auto_width_toggled_off_after_on(snap_compare):
233+
"""Snapshot after toggling auto width ON then OFF — should match original layout."""
234+
assert snap_compare(
235+
_make_varying_width_app(num_rows=100),
236+
press=["w", "w"],
237+
terminal_size=(120, 30),
238+
)

0 commit comments

Comments
 (0)