Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
4ab9204
change text to content
willmcgugan Jul 12, 2025
8716980
tables
willmcgugan Jul 12, 2025
9a804df
table grid WIP
willmcgugan Jul 12, 2025
4951338
tooltips
willmcgugan Jul 12, 2025
6f89a33
table updates
willmcgugan Jul 13, 2025
5e883eb
tidy
willmcgugan Jul 13, 2025
4a656f2
table crushing
willmcgugan Jul 14, 2025
615163c
fix edge cases
willmcgugan Jul 14, 2025
251753c
style inline code differently in light themes
willmcgugan Jul 14, 2025
0894fb0
tidy
willmcgugan Jul 14, 2025
51eaa06
refine markdown, fix test
willmcgugan Jul 14, 2025
fb5fab3
header align
willmcgugan Jul 14, 2025
df8e4da
simplify
willmcgugan Jul 14, 2025
509bcf6
simplify
willmcgugan Jul 14, 2025
9fb7d77
repair table crush
willmcgugan Jul 14, 2025
d025fa8
snapshot test
willmcgugan Jul 15, 2025
bc45a3d
changelog
willmcgugan Jul 15, 2025
a5a8fc6
changelog
willmcgugan Jul 15, 2025
1151113
markdown background and snapshots
willmcgugan Jul 15, 2025
2d055cc
update dependancies
willmcgugan Jul 15, 2025
aad26d5
pause markdown
willmcgugan Jul 15, 2025
ed2f9aa
fix flakey test
willmcgugan Jul 15, 2025
d34e467
update bragging rights
willmcgugan Jul 15, 2025
ed6f091
wait for snapshots
willmcgugan Jul 15, 2025
2178a00
name
willmcgugan Jul 15, 2025
aed0b47
better copy
willmcgugan Jul 15, 2025
861f842
fix for flakeyness
willmcgugan Jul 15, 2025
1318b7a
restore width
willmcgugan Jul 15, 2025
c1c19d2
possible fix
willmcgugan Jul 15, 2025
629bcba
wait longer
willmcgugan Jul 15, 2025
ad2bd6d
run before
willmcgugan Jul 15, 2025
9788045
fix initial markdown
willmcgugan Jul 15, 2025
41c4ba6
flakey test
willmcgugan Jul 15, 2025
34a698a
delay in test
willmcgugan Jul 15, 2025
3a02d44
flaky test
willmcgugan Jul 15, 2025
bbc6224
test fix
willmcgugan Jul 15, 2025
fb062bc
viewer fix
willmcgugan Jul 15, 2025
95be172
maybe fix
willmcgugan Jul 15, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### Added

- Added get_minimal_width to Visual protocol https://github.com/Textualize/textual/pull/5962
- Added `expand` and `shrink` attributes to GridLayout https://github.com/Textualize/textual/pull/5962

### Changed

- Improved rendering of Markdown tables (replace Rich table with grid) which allows text selection https://github.com/Textualize/textual/pull/5962

## [4.0.0] - 2025-07-12

Expand Down
18 changes: 9 additions & 9 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion src/textual/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"[b magenta]Hope you liked the demo![/]\n\n"
"Please consider sponsoring me if you get value from my work.\n\n"
"Even the price of a ☕ can brighten my day!\n\n"
"https://github.com/sponsors/willmcgugan",
"https://github.com/sponsors/willmcgugan\n\n"
"- Will McGugan",
border_style="red",
title="Consider sponsoring",
)
Expand Down
5 changes: 2 additions & 3 deletions src/textual/_node_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,8 @@ def _ensure_unique_id(self, widget_id: str) -> None:
"""
if widget_id in self._nodes_by_id:
raise DuplicateIds(
f"Tried to insert a widget with ID {widget_id!r}, but a widget {self._nodes_by_id[widget_id]!r} "
"already exists with that ID in this list of children. "
"The children of a widget must have unique IDs."
f"Tried to insert a widget with ID {widget_id!r}, but a widget already exists with that ID ({self._nodes_by_id[widget_id]!r}); "
"ensure all child widgets have a unique ID."
)

def _remove(self, widget: Widget) -> None:
Expand Down
47 changes: 40 additions & 7 deletions src/textual/_resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ def resolve(
gutter: int,
size: Size,
viewport: Size,
min_size: int | None = None,
*,
expand: bool = False,
shrink: bool = False,
minimums: list[int] | None = None,
) -> list[tuple[int, int]]:
"""Resolve a list of dimensions.

Expand Down Expand Up @@ -49,8 +52,8 @@ def resolve(
sum([scalar.value for scalar, fraction in resolved if fraction is None])
)

total_gutter = gutter * (len(dimensions) - 1)
if total_fraction:
total_gutter = gutter * (len(dimensions) - 1)
consumed = sum([fraction for _, fraction in resolved if fraction is not None])
remaining = max(Fraction(0), Fraction(total - total_gutter) - consumed)
fraction_unit = Fraction(remaining, total_fraction)
Expand All @@ -63,12 +66,42 @@ def resolve(
"list[Fraction]", [fraction for _, fraction in resolved]
)

if min_size is not None:
resolved_fractions = [
max(Fraction(min_size), fraction) for fraction in resolved_fractions
]

fraction_gutter = Fraction(gutter)

if expand or shrink:
total_space = total - total_gutter
used_space = sum(resolved_fractions)
if expand:
remaining_space = total_space - used_space
if remaining_space > 0:
resolved_fractions = [
width + Fraction(width, used_space) * remaining_space
for width in resolved_fractions
]
if shrink:
one = Fraction(1)
excess_space = used_space - total_space
if minimums is not None and excess_space > 0:
for index, (minimum_width, width) in enumerate(
zip(map(Fraction, minimums), resolved_fractions)
):
remove_space = max(Fraction(width, used_space), one) * excess_space
updated_width = max(minimum_width, width - remove_space)
resolved_fractions[index] = updated_width
used_space = used_space - width + updated_width
excess_space = used_space - total_space
if excess_space <= 0:
break

used_space = sum(resolved_fractions)
excess_space = used_space - total_space

if excess_space > 0:
resolved_fractions = [
width - Fraction(width, used_space) * excess_space
for width in resolved_fractions
]

offsets = [0] + [
fraction.__floor__()
for fraction in accumulate(
Expand Down
1 change: 0 additions & 1 deletion src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3985,7 +3985,6 @@ async def run_action(
action_target, action_name, params = self._parse_action(
action, self if default_namespace is None else default_namespace
)

if action_target.check_action(action_name, params):
return await self._dispatch_action(action_target, action_name, params)
else:
Expand Down
4 changes: 3 additions & 1 deletion src/textual/canvas.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,9 @@ def render(
text[offset:next_offset],
base_style
+ Style.from_color(
colors[max(color_indices)].rich_color
colors[
max(color_indices) if color_indices else 0
].rich_color
),
)
)
Expand Down
16 changes: 16 additions & 0 deletions src/textual/content.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def __init__(
self._spans: list[Span] = [] if spans is None else spans
self._cell_length = cell_length
self._optimal_width_cache: int | None = None
self._minimal_width_cache: int | None = None
self._height_cache: tuple[tuple[int, str, bool] | None, int] = (None, 0)

def __str__(self) -> str:
Expand Down Expand Up @@ -441,6 +442,21 @@ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:
width = self._optimal_width_cache
return width + rules.get("line_pad", 0) * 2

def get_minimal_width(self, rules: RulesMap) -> int:
"""Minimal width is the largest single word."""
if not self.plain.strip():
return 0
if self._minimal_width_cache is None:
self._minimal_width_cache = width = max(
cell_len(word)
for line in self.plain.splitlines()
for word in line.split()
if word.strip()
)
else:
width = self._minimal_width_cache
return width + rules.get("line_pad", 0) * 2

def get_height(self, rules: RulesMap, width: int) -> int:
"""Get the height of the Visual if rendered at the given width.

Expand Down
2 changes: 1 addition & 1 deletion src/textual/demo/home.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ def compose(self) -> ComposeResult:

## Built on Rich

With over 1.6 *billion* downloads, Rich is the most popular terminal library out there.
With over 3.1 *billion* downloads, Rich is the most popular terminal library out there.
Textual builds on Rich to add interactivity, and is fully-compatible with Rich renderables.

## Re-usable widgets
Expand Down
30 changes: 29 additions & 1 deletion src/textual/layouts/grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from textual.css.scalar import Scalar
from textual.geometry import NULL_OFFSET, Region, Size, Spacing
from textual.layout import ArrangeResult, Layout, WidgetPlacement
from textual.visual import visualize

if TYPE_CHECKING:
from textual.widget import Widget
Expand All @@ -20,7 +21,14 @@ class GridLayout(Layout):
def __init__(self) -> None:
self.min_column_width: int | None = None
self.stretch_height: bool = False
self.regular = False
"""Stretch the height of cells to be equal in each row."""
self.regular: bool = False
self.expand: bool = False
"""Expand the grid to fit the container if it is smaller."""
self.shrink: bool = False
"""Shrink the grid to fit the container if it is larger."""
self.auto_minimum: bool = False
"""If self.shrink is `True`, auto-detect and limit the width."""

def arrange(
self, parent: Widget, children: list[Widget], size: Size
Expand Down Expand Up @@ -222,12 +230,32 @@ def apply_height_limits(widget: Widget, height: int) -> int:
)
column_scalars[column] = Scalar.from_number(width)

column_minimums: list[int] | None = None
if self.auto_minimum and self.shrink:
column_minimums = [1] * table_size_columns
for column_index in range(table_size_columns):
for row_index in range(len(row_scalars)):
if (
cell_info := cell_map.get((column_index, row_index))
) is not None:
widget = cell_info[0]
column_minimums[column_index] = max(
visualize(widget, widget.render()).get_minimal_width(
widget.styles
)
+ widget.styles.gutter.width,
column_minimums[column_index],
)

columns = resolve(
column_scalars,
size.width,
gutter_vertical,
size,
viewport,
expand=self.expand,
shrink=self.shrink,
minimums=column_minimums,
)

# Handle any auto rows
Expand Down
14 changes: 14 additions & 0 deletions src/textual/visual.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,20 @@ def get_optimal_width(self, rules: RulesMap, container_width: int) -> int:

"""

def get_minimal_width(self, rules: RulesMap) -> int:
"""Get a minimal width (the smallest width before data loss occurs).

Args:
rules: A mapping of style rules, such as the Widgets `styles` object.
container_width: The width of the container, used by Rich Renderables.
May be ignored for Textual Visuals.

Returns:
A width in cells.

"""
return 1

@abstractmethod
def get_height(self, rules: RulesMap, width: int) -> int:
"""Get the height of the Visual if rendered at the given width.
Expand Down
4 changes: 3 additions & 1 deletion src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -756,7 +756,7 @@ def tooltip(self, tooltip: VisualType | None):
except NoScreen:
pass

def with_tooltip(self, tooltip: RenderableType | None) -> Self:
def with_tooltip(self, tooltip: Visual | RenderableType | None) -> Self:
"""Chainable method to set a tooltip.

Example:
Expand Down Expand Up @@ -3652,6 +3652,8 @@ def __rich_repr__(self) -> rich.repr.Result:
yield "id", self.id, None
if self.name:
yield "name", self.name
if self.classes:
yield "classes", " ".join(self.classes)
except AttributeError:
pass

Expand Down
1 change: 1 addition & 0 deletions src/textual/widgets/_collapsible.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ def _watch_collapsed(self, collapsed: bool) -> None:
self.post_message(self.Collapsed(self))
else:
self.post_message(self.Expanded(self))
if self.is_mounted:
self.call_after_refresh(self.scroll_visible)

def _update_collapsed(self, collapsed: bool) -> None:
Expand Down
Loading
Loading