Skip to content

Narrow Screen.refresh(Region) can discard another widget's repaint #6723

Open

Description

@jamespharaoh

This bug report was created with the help of ChatGPT-Sol-6.

Textual 8.2.8 (Rich 15.0.0), Python 3.13.5, Linux, 158×63 terminal. The same controlled trigger also fails on Textual 2.1.2. This is visible in the raw terminal output and in an isolated tmux capture, so it is not a terminal emulator rendering artifact.

I'll add a comment with a fix and regression test.

To reproduce, save the standalone script below as textual_partial_refresh_repro.py, then run it in a terminal:

uv run textual_partial_refresh_repro.py
uv run textual_partial_refresh_repro.py --full-rows  # control

Here's what this looks like:

Image

This reproduction is based on how this issue manifested in my application, where a refresh to a widget on the main screen, which updates on a timer, was corrupting the display of a modal dialog above it while scrolling. I'll post a single-screen reproduction case as well in a comment below.

The script's inline metadata installs the stock Textual 8.2.8 and Rich 15.0.0 into an isolated environment. Press c in each run. Every third row has a sparse fill, and every row repeats its numbered marker across the width. In the first run, the left and right sides remain four rows apart while idle, so the sparse rows no longer line up. For example, the first content row has [0000:A] on the left and [0004:E] on the right. Pressing f redraws the screen and repairs it. With --full-rows, the markers stay aligned; the option changes only the width of Screen.refresh(Region).

The script uses public Textual APIs: ScrollView.scroll_to, set_timer, call_later, and Screen.refresh(Region). It uses no private Textual APIs, worker threads, network calls, or subprocesses. It deliberately arranges the event order so the problem is repeatable; a natural background update in a smaller app has not reliably reached the same ordering.

In the failing frame, the pending full-width scrolling child repaint and a later narrow screen refresh are both dirty before the paint timer. Screen._on_timer_update clears _dirty_widgets when _repaint_required is true, then adds only the screen. This appears to discard the child's pending repaint. The clearing is general: a separate non-scrolling Static test reaches the failure using only public Widget.refresh() and Screen.refresh(Region) calls. A public-API test with a single screen reproduces the partial update loss too; the modal and background screen are not required. Retaining all dirty widgets fixes these tests. In an isolated 158×63 tmux capture, the standalone scrolling repro has 52 persistent split rows on released 8.2.8, zero with the patch, and zero with --full-rows.

A trace from that application showed a coherent full compositor render followed by a partial driver write that covered only the left portion of rows where the reader and scrollbar had changed. The application symptom has not recurred after upgrading to 8.2.8, although the standalone trigger still fails on that release. Related reports: #5099 (older partial-update scroll glitch) and #5918 (scroll paint repaired by redraw). Neither appears to describe this exact coalescing order.

Standalone reproducer (textual_partial_refresh_repro.py):

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = ["textual==8.2.8", "rich==15.0.0"]
# ///
"""Reproduce a persistent split scroll row with public Textual APIs.

Run `uv run textual_partial_refresh_repro.py` in a terminal, then press c.
The left and right marker numbers on each row should match. Press f to
repair the display. Run with --full-rows as a control: pressing c then
leaves all rows aligned.

This program uses only Textual and Rich. It has no worker thread, background
screen, private Textual calls, network access, or subprocesses.
"""

from __future__ import annotations

import argparse

from rich.segment import Segment
from rich.style import Style
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
from textual.geometry import Region, Size
from textual.screen import ModalScreen
from textual.scroll_view import ScrollView
from textual.strip import Strip
from textual.widgets import Static


def content(rows: int = 500) -> list[Strip]:
    """Put a row number on both sides of the expected repaint boundary."""
    strips = []
    for number in range(rows):
        letter = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"[number % 32]
        line = list((" " if number % 3 == 2 else letter) * 128)
        for column in (0, 48, 64, 112):
            marker = f"[{number:04d}:{letter}]"
            line[column:column + len(marker)] = marker
        strips.append(Strip([Segment("".join(line), Style())]))
    return strips


class Reader(ScrollView):
    can_focus = True

    def __init__(self) -> None:
        super().__init__(id="reader")
        self.lines = content()

    def on_mount(self) -> None:
        self.virtual_size = Size(128, len(self.lines))

    def render_line(self, y: int) -> Strip:
        width = self.scrollable_content_region.width
        index = y + self.scroll_offset.y
        if index >= len(self.lines):
            return Strip.blank(width, Style())
        return self.lines[index].crop(
            self.scroll_offset.x, self.scroll_offset.x + width,
        ).extend_cell_length(width, Style())


class ReaderScreen(ModalScreen):
    BINDINGS = [
        Binding("c", "collision", "Trigger split"),
        Binding("f", "redraw", "Redraw"),
        Binding("q", "quit", "Quit"),
    ]

    def compose(self) -> ComposeResult:
        with Vertical(id="dialog"):
            yield Static("c: trigger split | f: redraw | q: quit")
            yield Reader()

    def on_mount(self) -> None:
        self.query_one(Reader).focus()

    def action_collision(self) -> None:
        reader = self.query_one(Reader)
        reader.scroll_to(y=4, animate=False, immediate=True)
        self.set_timer(0.25, self._finish_collision)

    def _finish_collision(self) -> None:
        reader = self.query_one(Reader)
        reader.scroll_to(y=0, animate=False, immediate=True)
        region = (Region(0, reader.region.y, self.size.width, reader.region.height)
                  if self.app.full_rows else Region(2, reader.region.y,
                      self.size.width // 2 - 2, reader.region.height))
        reader.call_later(self.refresh, region)

    def action_redraw(self) -> None:
        self.app.refresh(layout=True)

    def action_quit(self) -> None:
        self.app.exit()


class ReproApp(App):
    CSS = """
    ReaderScreen { align: center middle; background: $background 60%; }
    #dialog { width: 96%; height: 92%; border: heavy $primary;
        background: $surface; padding: 1 2; }
    #reader { height: 1fr; }
    """

    def __init__(self, *, full_rows: bool) -> None:
        super().__init__()
        self.full_rows = full_rows

    def on_mount(self) -> None:
        self.push_screen(ReaderScreen())


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--full-rows", action="store_true")
    args = parser.parse_args()
    ReproApp(full_rows=args.full_rows).run()


if __name__ == "__main__":
    main()

Activity

  1. github-actions commented on Sep 29, 2026

    @github-actions

    We found the following entries in the FAQ which you may find helpful:

    Feel free to close this issue if you found an answer in the FAQ. Otherwise, please give us a little time to review.

    This project is developed and maintained by Will McGugan. Consider sponsoring Will's work on this project (and others).

    This is an automated reply, generated by FAQtory

  2. jamespharaoh commented on Sep 29, 2026

    @jamespharaoh
    Author

    I have a proposed fix, along with a regression test, in my fork jamespharaoh/textual.

    When Screen._repaint_required is set, _on_timer_update clears _dirty_widgets before adding the screen. A narrow screen refresh can therefore discard another widget’s pending wider repaint. The patch retains the pending dirty widgets and adds the screen so the compositor can combine their repaint regions.

    The regression test covers a narrow refresh on a single screen, a full-row control, and a non-scrolling Static widget. On released 8.2.8, the two narrow cases fail and the control passes; all three pass with the patch.

    @willmcgugan, does this look suitable for a PR? I am happy to make any changes etc based on feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions