Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b28799f
CI tests the Pythons it can provision: 3.9 never set up, and fail-fas…
trissim Nov 6, 2026
43063f9
CI starts at Python 3.12: the source already uses 3.12 syntax
trissim Nov 6, 2026
40187c1
test_message_inheritance_namespace asserts namespaces, not two pumps'…
trissim Nov 6, 2026
48b4dd6
Paint tests take the PaintState render_line contract; the publish spy…
trissim Nov 6, 2026
0705418
Stepped and paint tests read the compositor's one-step frame contract
trissim Nov 6, 2026
f272b9d
Mount tests set is_leaving, the property AwaitMount reads
trissim Nov 6, 2026
7b268a2
Editor-state tests await restore_editor_state
trissim Nov 6, 2026
9ec4967
Viewport overlay test asserts no intrinsic clip, not a cache entry
trissim Nov 6, 2026
b9e9b2b
Delete the debt-ratchet and black workflows: neither can pass
trissim Nov 6, 2026
c006186
test_lazy checks the first drawn frame, not when run_test returns
trissim Nov 6, 2026
4c25c83
A pushed screen is restyled with the CSS loaded for it
trissim Nov 6, 2026
7d0e6c7
Modal pop test clicks the Quit footer key, not the first FooterKey
trissim Nov 6, 2026
c3dd2b6
Hover path-key test asserts the hovered colour, not a rule re-check c…
trissim Nov 6, 2026
732a223
Focus-order acquisition test asserts exactly the unplaced buttons are…
trissim Nov 6, 2026
a21208c
A complete intrinsic capture serves a viewport request, like a placed…
trissim Nov 6, 2026
ffb578d
A container is declared by its layout, so an empty one is still a con…
trissim Nov 6, 2026
9420da6
The paint digest test pins its terminal to truecolor
trissim Nov 6, 2026
e25defb
Navigation spellings test checks each arrow's modal spelling by actio…
trissim Nov 6, 2026
a50a10d
Keylines draw for containers that declare their layout in the class
trissim Nov 6, 2026
de83ad6
A key sent to a hidden focused widget still reaches its ancestors
trissim Nov 6, 2026
a3e3fc1
A rule set to initial reads as its declared default, never None
trissim Nov 6, 2026
a87ee2f
Line API examples and snapshot apps take render_line(y, paint)
trissim Nov 6, 2026
1496e82
The paste snapshot fills the clipboard through its owner
trissim Nov 6, 2026
882b6f7
The app's widgets mount before the screen's own tooltip and toasts
trissim Nov 6, 2026
a590a8a
Cursor commands are ordinary bindings, not priority ones
trissim Nov 6, 2026
edbfd5b
A named action on an arrow keeps the id its author declared
trissim Nov 6, 2026
ecdc214
Every console in a test session is one truecolor terminal
trissim Nov 6, 2026
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
22 changes: 0 additions & 22 deletions .github/workflows/black_format.yml

This file was deleted.

34 changes: 0 additions & 34 deletions .github/workflows/debt-ratchet.yml

This file was deleted.

13 changes: 2 additions & 11 deletions .github/workflows/pythonpackage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
python-version: ["3.12", "3.13", "3.14"]
defaults:
run:
shell: bash
Expand All @@ -35,18 +35,9 @@ jobs:
cache: "poetry"
- name: Install dependencies
run: poetry install --no-interaction --extras syntax
if: ${{ matrix.python-version != '3.9' }}
- name: Install dependencies for 3.9
run: poetry install --no-interaction
if: ${{ matrix.python-version == '3.9' }}
- name: Test with pytest (Py310+ - with syntax highlighting)
- name: Test with pytest
run: |
poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing
if: ${{ matrix.python-version != '3.9' }}
- name: Test with pytest (Py39 - without syntax highlighting)
run: |
poetry run pytest tests -v --cov=./src/textual --cov-report=xml:./coverage.xml --cov-report term-missing -m 'not syntax'
if: ${{ matrix.python-version == '3.9' }}
- name: Upload snapshot report
if: always()
uses: actions/upload-artifact@v4
Expand Down
3 changes: 2 additions & 1 deletion docs/examples/guide/widgets/checker01.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@
from textual.app import App, ComposeResult
from textual.strip import Strip
from textual.widget import Widget
from textual._paint_state import PaintState


class CheckerBoard(Widget):
"""Render an 8x8 checkerboard."""

def render_line(self, y: int) -> Strip:
def render_line(self, y: int, paint: PaintState) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""

row_index = y // 4 # A checkerboard square consists of 4 rows
Expand Down
3 changes: 2 additions & 1 deletion docs/examples/guide/widgets/checker02.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from textual.app import App, ComposeResult
from textual.strip import Strip
from textual.widget import Widget
from textual._paint_state import PaintState


class CheckerBoard(Widget):
Expand All @@ -22,7 +23,7 @@ class CheckerBoard(Widget):
}
"""

def render_line(self, y: int) -> Strip:
def render_line(self, y: int, paint: PaintState) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""

row_index = y // 4 # four lines per row
Expand Down
3 changes: 2 additions & 1 deletion docs/examples/guide/widgets/checker03.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from textual.scroll_view import ScrollView

from rich.segment import Segment
from textual._paint_state import PaintState


class CheckerBoard(ScrollView):
Expand All @@ -29,7 +30,7 @@ def __init__(self, board_size: int) -> None:
# Each square is 4 rows and 8 columns
self.virtual_size = Size(board_size * 8, board_size * 4)

def render_line(self, y: int) -> Strip:
def render_line(self, y: int, paint: PaintState) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""

scroll_x, scroll_y = self.scroll_offset # The current scroll position
Expand Down
3 changes: 2 additions & 1 deletion docs/examples/guide/widgets/checker04.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from rich.segment import Segment
from rich.style import Style
from textual._paint_state import PaintState


class CheckerBoard(ScrollView):
Expand Down Expand Up @@ -62,7 +63,7 @@ def get_square_region(square_offset: Offset) -> Region:
# Refresh the new cursor square
self.refresh(get_square_region(cursor_square))

def render_line(self, y: int) -> Strip:
def render_line(self, y: int, paint: PaintState) -> Strip:
"""Render a line of the widget. y is relative to the top of the widget."""

scroll_x, scroll_y = self.scroll_offset # The current scroll position
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ Textual offers an alternative API which reduces the amount of work required to r

### Render Line method

To build a widget with the line API, implement a `render_line` method rather than a `render` method. The `render_line` method takes a single integer argument `y` which is an offset from the top of the widget, and should return a [Strip][textual.strip.Strip] object containing that line's content.
To build a widget with the line API, implement a `render_line` method rather than a `render` method. The `render_line` method takes an integer `y`, which is an offset from the top of the widget, and the widget's resolved `paint` state (its colors, text style and opacity for this frame), and should return a [Strip][textual.strip.Strip] object containing that line's content.
Textual will call this method as required to get content for every row of characters in the widget.

<div class="excalidraw">
Expand Down
32 changes: 19 additions & 13 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,9 +412,20 @@ def _spatial_map(self) -> SpatialMap[tuple[int, Widget]]:
)
return spatial_map

@abstractmethod
def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool:
...
"""The same arrangement, or a complete one serving a viewport request."""
if require_complete and not self.complete:
return False
mine, wanted = self._comparable(self.key), self._comparable(key)
return mine == wanted or (
not mine.visible_only and wanted.visible_only
and mine._replace(visible_only=True) == wanted
)

@staticmethod
@abstractmethod
def _comparable(key: SubtreeGeometryKey) -> SubtreeGeometryKey:
"""The inputs this resource's placements depend on."""

def restore_into(
self, geometry: CompositorMap, widgets: set[Widget], invisible_widgets: set[Widget],
Expand Down Expand Up @@ -515,13 +526,9 @@ def references_retired(self, owner: Widget, retired: set[Widget]) -> bool:
class PlacedSubtreeGeometry(SubtreeGeometry[MapGeometry]):
"""A culled or screen-dependent arrangement keeps its exact placement."""

def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool:
if require_complete and not self.complete:
return False
return self.key == key or (
not self.key.visible_only and key.visible_only
and self.key._replace(visible_only=True) == key
)
@staticmethod
def _comparable(key: SubtreeGeometryKey) -> SubtreeGeometryKey:
return key

def _project_entry(self, entry: MapGeometry, key: SubtreeGeometryKey,
clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]:
Expand Down Expand Up @@ -560,10 +567,9 @@ def capture(cls, key, geometry, widgets, invisible_widgets, clips, clip, screen_
return super().capture(key, intrinsic, widgets, invisible_widgets,
clips, clip, screen_coordinates)

def matches(self, key: SubtreeGeometryKey, *, require_complete: bool = False) -> bool:
if require_complete and not self.complete:
return False
return self.key.intrinsic() == key.intrinsic()
@staticmethod
def _comparable(key: SubtreeGeometryKey) -> SubtreeGeometryKey:
return key.intrinsic()

def _project_entry(self, entry: SubtreeMapGeometry, key: SubtreeGeometryKey,
clip: SceneClip, *, root: bool) -> tuple[MapGeometry, SceneClip]:
Expand Down
11 changes: 9 additions & 2 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2940,6 +2940,10 @@ def _load_screen_css(self, screen: Screen):
if update:
self.stylesheet.reparse()
self.stylesheet.update(self)
# The screen was mounted, and styled, before its own CSS was read,
# and a pushed screen is not on a stack yet for the source change to
# queue it: this screen is the one the new rules are for.
self.stylesheet.update(screen)

async def _replace_screen(self, screen: Screen) -> Screen:
"""Handle the replaced screen.
Expand Down Expand Up @@ -3649,13 +3653,16 @@ async def _on_compose(self) -> None:
_rich_traceback_omit = True
self._compose_screen = self.screen
try:
widgets = [*self.screen._nodes, *await compose_async(self)]
widgets = await compose_async(self)
except TypeError as error:
raise TypeError(
f"{self!r} compose() method returned an invalid result; {error}"
) from error

await self.mount_all(widgets)
# The screen's own widgets (tooltip, toasts) are already mounted. The
# app's widgets go in front of them, so the app's queries, such as
# query_one(Static) in on_mount, find its own widgets first.
await self.mount_all(widgets, before=0 if self.screen._nodes else None)
# The screen resumed before these widgets existed, so its own
# auto-focus pass found nothing to focus.
self._compose_screen._update_auto_focus()
Expand Down
15 changes: 14 additions & 1 deletion src/textual/binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,9 +128,18 @@ def command_invocations(self) -> tuple[KeyInvocation, ...]:
def invocations(self) -> tuple[KeyInvocation, ...]:
return self.declared_key.invocations(self.command_invocations())

def shared_identity(self, mode: type[EditingMode]) -> str | None:
"""The arrow preference this binding's key shares in the mode, if its key is an arrow."""
return next((invocation.binding_identity(mode) for invocation in self.invocations()), self.id)

def preference_identity(self, mode: type[EditingMode]) -> str | None:
"""A named action keeps the id its author declared; without one it joins its arrow's
shared preference, so a keymap for that id replaces every key the author listed."""
return self.id if self.id is not None else self.shared_identity(mode)

def for_mode(self, mode: type[EditingMode]) -> tuple[Self, ...]:
"""Give native arrows their shared preference identity in the actual mode."""
identity = next((invocation.binding_identity(mode) for invocation in self.invocations()), self.id)
identity = self.preference_identity(mode)
return tuple(
dataclasses.replace(self, key=spelling, id=identity)
for spelling in self.declared_key.spellings(mode, self.key)
Expand Down Expand Up @@ -293,6 +302,10 @@ def invocation(self) -> KeyInvocation:
def command_invocations(self) -> tuple[KeyInvocation, ...]:
return (self.invocation,)

def preference_identity(self, mode: type[EditingMode]) -> str | None:
"""Native commands on an arrow always share its preference, whatever id they carry."""
return self.shared_identity(mode)

def key_identity(self) -> str:
return self.qualified(self.action)

Expand Down
17 changes: 17 additions & 0 deletions src/textual/containers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from __future__ import annotations

from textual.layouts.vertical import VerticalLayout
from textual.layout import Layout
from textual.navigation import ScrollCommand
from textual.layouts.grid import GridLayout
Expand All @@ -18,6 +19,8 @@
class Container(Widget):
"""Simple container widget, with vertical layout."""

DEFAULT_LAYOUT = VerticalLayout

DEFAULT_CSS = """
Container {
width: 1fr;
Expand All @@ -30,6 +33,8 @@ class Container(Widget):
class ScrollableContainer(Widget, can_focus=True, key_families=(ScrollCommand,)):
"""A scrollable container with vertical layout, and auto scrollbars on both axis."""

DEFAULT_LAYOUT = VerticalLayout

# We don't typically want to maximize scrollable containers,
# since the user can easily navigate the contents
ALLOW_MAXIMIZE = False
Expand Down Expand Up @@ -90,6 +95,8 @@ def allow_maximize(self) -> bool:
class Vertical(Widget):
"""An expanding container with vertical layout and no scrollbars."""

DEFAULT_LAYOUT = VerticalLayout

DEFAULT_CSS = """
Vertical {
width: 1fr;
Expand All @@ -102,6 +109,8 @@ class Vertical(Widget):
class VerticalGroup(Widget):
"""A non-expanding container with vertical layout and no scrollbars."""

DEFAULT_LAYOUT = VerticalLayout

DEFAULT_CSS = """
VerticalGroup {
width: 1fr;
Expand Down Expand Up @@ -166,6 +175,8 @@ class HorizontalScroll(ScrollableContainer):
class Center(Widget):
"""A container which aligns children on the X axis."""

DEFAULT_LAYOUT = VerticalLayout

DEFAULT_CSS = """
Center {
align-horizontal: center;
Expand All @@ -178,6 +189,8 @@ class Center(Widget):
class Right(Widget):
"""A container which aligns children on the X axis."""

DEFAULT_LAYOUT = VerticalLayout

DEFAULT_CSS = """
Right {
align-horizontal: right;
Expand All @@ -190,6 +203,8 @@ class Right(Widget):
class Middle(Widget):
"""A container which aligns children on the Y axis."""

DEFAULT_LAYOUT = VerticalLayout

DEFAULT_CSS = """
Middle {
align-vertical: middle;
Expand All @@ -202,6 +217,8 @@ class Middle(Widget):
class CenterMiddle(Widget):
"""A container which aligns its children on both axis."""

DEFAULT_LAYOUT = VerticalLayout

DEFAULT_CSS = """
CenterMiddle {
align: center middle;
Expand Down
11 changes: 9 additions & 2 deletions src/textual/css/_style_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,15 @@ def __get__(self, obj: None, objtype: type[StylesBase] | None = None) -> Self: .
def __get__(self, obj: StylesBase, objtype: type[StylesBase] | None = None) -> PropertyGetType: ...

def __get__(self, obj: StylesBase | None, objtype: type[StylesBase] | None = None):
"""Read the rule, or the declared default when it is unset."""
return self if obj is None else obj.get_rule(self.name, self.default)
"""Read the rule, or the declared default when it is unset or `initial`.

The builder stores `initial` as None so it still overrides less specific
rules; reading it answers the default, never None.
"""
if obj is None:
return self
value = obj.get_rule(self.name)
return self.default if value is None else value

def __set_name__(self, owner: type[StylesBase], name: str) -> None:
self.name = name
Expand Down
2 changes: 1 addition & 1 deletion src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ def _render_container(self):
"""Native container paint, shared independently of scene custody."""
from textual.renderables.blank import Blank

if self.geometry.child_layout and self.geometry.keyline[0] != "none":
if self.is_container and self.geometry.keyline[0] != "none":
return self.layout.render_keyline(self)
return Blank(self.background_colors[1])

Expand Down
2 changes: 2 additions & 0 deletions src/textual/layout.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ class Layout(ABC):
"""Base class of the object responsible for arranging Widgets within a container."""

name: ClassVar[str] = ""
holds_children: ClassVar[bool] = True
"""A declared layout makes its widget a container, with or without children."""
_content_width_dependency: ClassVar[HeightDependency]
_content_height_dependency: ClassVar[HeightDependency]
_arrangement_height_dependency: ClassVar[HeightDependency]
Expand Down
Loading
Loading