Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 16 additions & 4 deletions src/prompt_toolkit/application/run_in_terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,22 @@ async def f():
finally:
# Redraw interface again.
try:
app._running_in_terminal = False
app.renderer.reset()
app._request_absolute_cursor_position()
app._redraw()
try:
app.renderer.reset()
app._request_absolute_cursor_position()

# Wait for the CPR response before redrawing (rendering is
# still disabled). Otherwise, the first paint doesn't know how
# much space is available below the cursor and gets the
# layout's preferred height, right below the new output. Once
# the response arrives, the layout would be painted again,
# extended to the bottom of the terminal, which looks like a
# jump.
if app.output.responds_to_cpr:
await app.renderer.wait_for_cpr_responses()
finally:
app._running_in_terminal = False
app._redraw()
finally:
# (Check for `.done()`, because it can be that this future was
# cancelled.)
Expand Down
82 changes: 82 additions & 0 deletions tests/test_run_in_terminal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"""
Test how `in_terminal` repaints the application afterwards.
"""

from __future__ import annotations

import asyncio
import io

from prompt_toolkit.application import Application, in_terminal
from prompt_toolkit.data_structures import Size
from prompt_toolkit.input.defaults import create_pipe_input
from prompt_toolkit.layout import FormattedTextControl, HSplit, Layout, Window
from prompt_toolkit.output.vt100 import Vt100_Output


class _TTY(io.StringIO):
"Emulate a terminal stdout, so that the output responds to CPR."

def isatty(self) -> bool:
return True


def _run(send_cpr_response: bool) -> list[bool]:
"""
Run an inline application, run `in_terminal` once it got its first CPR
response, and return for each paint after that (until exiting) whether
the renderer knew the space below the cursor.
"""
heights_known: list[bool] = []

async def main() -> None:
with create_pipe_input() as inp:
app: Application[None] = Application(
layout=Layout(
HSplit(
[
Window(FormattedTextControl("input"), height=1),
Window(),
Window(FormattedTextControl("toolbar"), height=1),
]
)
),
input=inp,
output=Vt100_Output(_TTY(), lambda: Size(rows=24, columns=80)),
)

async def suspend() -> None:
# Answer the CPR request done when the application started.
inp.send_text("\x1b[1;1R")
await app.renderer.wait_for_cpr_responses()

def after_render(_: object) -> None:
if not app.is_done:
heights_known.append(app.renderer.height_is_known)

async with in_terminal():
app.after_render += after_render
if send_cpr_response:
# Input is detached here. This is read once it gets
# attached again, after `in_terminal` requests the
# cursor position.
inp.send_text("\x1b[5;1R")

app.exit()

app.pre_run_callables.append(lambda: app.create_background_task(suspend()))
await app.run_async()

asyncio.run(main())
return heights_known


def test_in_terminal_repaints_after_cpr_response():
# Paint once, knowing the available height, so that the layout doesn't
# jump.
assert _run(send_cpr_response=True) == [True]


def test_in_terminal_repaints_without_cpr_response():
# When the response doesn't arrive, still repaint after the timeout.
assert _run(send_cpr_response=False) == [False]
Loading