[terminal buffering] Address PR feedback

This commit is contained in:
Olivier Philippon
2022-05-24 13:05:47 +01:00
parent 3486dc08b5
commit 7f27e70440
7 changed files with 107 additions and 131 deletions

View File

@@ -1,10 +1,6 @@
from typing import Dict, Tuple from typing import Dict, Tuple
from ._terminal_modes import ( from ._terminal_modes import Mode
get_mode_report_sequence,
Mode,
ModeReportParameter,
)
from .keys import Keys from .keys import Keys
# Mapping of vt100 escape codes to Keys. # Mapping of vt100 escape codes to Keys.
@@ -306,14 +302,6 @@ ANSI_SEQUENCES_KEYS: Dict[str, Tuple[Keys, ...]] = {
} }
# Mapping of escape codes to report whether they support a "mode" we requested. TERMINAL_MODES_ANSI_SEQUENCES: Dict[Mode, Dict[str, str]] = {
ANSI_SEQUENCES_MODE_REPORTS: Dict[str, Tuple[Mode, ModeReportParameter]] = {
get_mode_report_sequence(mode, parameter): (mode, parameter)
for mode, parameter in [
(mode, parameter) for parameter in ModeReportParameter for mode in Mode
]
}
TERMINAL_MODES_ANSI_SEQUENCES: Dict[Mode, dict] = {
Mode.SynchronizedOutput: {"start_sync": "\x1b[?2026h", "end_sync": "\x1b[?2026l"}, Mode.SynchronizedOutput: {"start_sync": "\x1b[?2026h", "end_sync": "\x1b[?2026l"},
} }

View File

@@ -10,65 +10,33 @@ class TerminalSupportedFeatures(NamedTuple):
Handles information about the features the current terminal emulator seems to support. Handles information about the features the current terminal emulator seems to support.
""" """
mode2026_synchronized_update: bool = False synchronised_output: bool = False
"""@link https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036""" """@link https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036"""
@classmethod def synchronized_output_start_sequence(self) -> str:
def from_autodetect(cls) -> TerminalSupportedFeatures:
"""
Tries to autodetect various features we can work with the terminal emulator we're running in,
and returns an instance of `TerminalSupportedFeatures` on which matching property will be set to `True`
for features that seem to be usable.
Returns:
TerminalSupportedFeatures: a new TerminalSupportedFeatures
"""
# Here we might detect some features later on, by checking the OS type, the env vars, etc.
# (the same way we were doing it to detect iTerm2 "synchronized update" mode)
# Detecting "mode2026" is complicated, as we have to use an async request/response
# machinery with the terminal emulator - for now we should just assume it's not supported.
# See the use of the Mode and ModeReportParameter classes in the Textual code to check this machinery.
mode2026_synchronized_update = False
return cls(
mode2026_synchronized_update=mode2026_synchronized_update,
)
@property
def supports_synchronized_update(self) -> bool:
"""
Tells the caller if the current terminal emulator seems to support "synchronised updates"
(i.e. "buffered" updates).
At the moment we only support the generic "mode 2026".
Returns:
bool: whether the terminal seems to support buffered mode or not
"""
return self.mode2026_synchronized_update
def synchronized_update_sequences(self) -> tuple[str, str]:
""" """
Returns the ANSI sequence that we should send to the terminal to tell it that Returns the ANSI sequence that we should send to the terminal to tell it that
it should buffer the content we're about to send, as well as the ANIS sequence to end the buffering. it should start buffering the content we're about to send.
If the terminal doesn't seem to support synchronised updates both strings will be empty. If the terminal doesn't seem to support synchronised updates the string will be empty.
Returns: Returns:
tuple[str, str]: the start and end ANSI sequences, respectively. They will both be empty strings str: the "synchronised output start" ANSI sequence. It will be ab empty string
if the terminal emulator doesn't seem to support the "synchronised updates" mode. if the terminal emulator doesn't seem to support the "synchronised updates" mode.
""" """
return ( if self.synchronised_output:
self._synchronized_update_start_sequence(),
self._synchronized_update_end_sequence(),
)
def _synchronized_update_start_sequence(self) -> str:
if self.mode2026_synchronized_update:
return TERMINAL_MODES_ANSI_SEQUENCES[Mode.SynchronizedOutput]["start_sync"] return TERMINAL_MODES_ANSI_SEQUENCES[Mode.SynchronizedOutput]["start_sync"]
return "" return ""
def _synchronized_update_end_sequence(self) -> str: def synchronized_output_end_sequence(self) -> str:
if self.mode2026_synchronized_update: """
Returns the ANSI sequence that we should send to the terminal to tell it that
it should stop buffering the content we're about to send.
If the terminal doesn't seem to support synchronised updates the string will be empty.
Returns:
str: the "synchronised output stop" ANSI sequence. It will be ab empty string
if the terminal emulator doesn't seem to support the "synchronised updates" mode.
"""
if self.synchronised_output:
return TERMINAL_MODES_ANSI_SEQUENCES[Mode.SynchronizedOutput]["end_sync"] return TERMINAL_MODES_ANSI_SEQUENCES[Mode.SynchronizedOutput]["end_sync"]
return "" return ""

View File

@@ -2,6 +2,12 @@ from __future__ import annotations
from enum import Enum, IntEnum, unique from enum import Enum, IntEnum, unique
import rich
import rich.repr
from textual._types import MessageTarget
from textual.message import Message
@unique @unique
class Mode(Enum): class Mode(Enum):
@@ -23,6 +29,47 @@ class ModeReportParameter(IntEnum):
PermanentlyReset = 4 PermanentlyReset = 4
@rich.repr.auto
class ModeReportResponse(Message):
"""Sent when the terminals responses to a "mode" (i.e. a supported feature) request"""
__slots__ = ["mode", "report_parameter"]
def __init__(
self, sender: MessageTarget, mode: Mode, report_parameter: ModeReportParameter
) -> None:
"""
Args:
sender (MessageTarget): The sender of the event
mode (Mode): The mode the terminal emulator is giving us results about
report_parameter (ModeReportParameter): The status of this mode for the terminal emulator
"""
super().__init__(sender)
self.mode = mode
self.report_parameter = report_parameter
@classmethod
def from_terminal_mode_response(
cls, sender: MessageTarget, mode_id: str, setting_parameter: str
) -> ModeReportResponse:
mode = Mode(mode_id)
report_parameter = ModeReportParameter(int(setting_parameter))
return ModeReportResponse(sender, mode, report_parameter)
def __rich_repr__(self) -> rich.repr.Result:
yield "mode", self.mode
yield "report_parameter", self.report_parameter
@property
def mode_is_supported(self) -> bool:
"""Return True if the mode seems to be supported by the terminal emulator.
Returns:
bool: True if it's supported. False otherwise.
"""
return self.report_parameter in MODE_REPORTS_PARAMETERS_INDICATING_SUPPORT
MODE_REPORTS_PARAMETERS_INDICATING_SUPPORT = frozenset( MODE_REPORTS_PARAMETERS_INDICATING_SUPPORT = frozenset(
{ {
ModeReportParameter.Set, ModeReportParameter.Set,
@@ -35,7 +82,3 @@ MODE_REPORTS_PARAMETERS_INDICATING_SUPPORT = frozenset(
def get_mode_request_sequence(mode: Mode) -> str: def get_mode_request_sequence(mode: Mode) -> str:
return "\033[?" + mode.value + "$p\n" return "\033[?" + mode.value + "$p\n"
def get_mode_report_sequence(mode: Mode, parameter: ModeReportParameter) -> str:
return f"\x1b[?{mode.value};{parameter.value}$y"

View File

@@ -7,11 +7,15 @@ from typing import Any, Callable, Generator, Iterable
from . import log from . import log
from . import events from . import events
from ._terminal_modes import ModeReportResponse
from ._types import MessageTarget from ._types import MessageTarget
from ._parser import Awaitable, Parser, TokenCallback from ._parser import Awaitable, Parser, TokenCallback
from ._ansi_sequences import ANSI_SEQUENCES_KEYS, ANSI_SEQUENCES_MODE_REPORTS from ._ansi_sequences import ANSI_SEQUENCES_KEYS
_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z") _re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")
_re_terminal_mode_response = re.compile(
"^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y"
)
class XTermParser(Parser[events.Event]): class XTermParser(Parser[events.Event]):
@@ -82,7 +86,6 @@ class XTermParser(Parser[events.Event]):
ESC = "\x1b" ESC = "\x1b"
read1 = self.read1 read1 = self.read1
get_key_ansi_sequence = ANSI_SEQUENCES_KEYS.get get_key_ansi_sequence = ANSI_SEQUENCES_KEYS.get
get_mode_report_sequence = ANSI_SEQUENCES_MODE_REPORTS.get
more_data = self.more_data more_data = self.more_data
while not self.is_eof: while not self.is_eof:
@@ -123,11 +126,14 @@ class XTermParser(Parser[events.Event]):
on_token(event) on_token(event)
break break
# Or a mode report? (i.e. the terminal telling us if it supports a mode we requested) # Or a mode report? (i.e. the terminal telling us if it supports a mode we requested)
mode_report_match = get_mode_report_sequence(sequence, None) mode_report_match = _re_terminal_mode_response.match(sequence)
if mode_report_match is not None: if mode_report_match is not None:
mode_report, parameter = mode_report_match message = ModeReportResponse.from_terminal_mode_response(
event = events.ModeReport(self.sender, mode_report, parameter) self.sender,
on_token(event) mode_report_match["mode_id"],
mode_report_match["setting_parameter"],
)
on_token(message)
break break
else: else:
keys = get_key_ansi_sequence(character, None) keys = get_key_ansi_sequence(character, None)

View File

@@ -23,8 +23,7 @@ from typing import (
) )
from ._terminal_features import TerminalSupportedFeatures from ._terminal_features import TerminalSupportedFeatures
from ._terminal_modes import Mode from ._terminal_modes import Mode, ModeReportResponse
from .events import ModeReport
if sys.version_info >= (3, 8): if sys.version_info >= (3, 8):
from typing import Literal from typing import Literal
@@ -146,7 +145,7 @@ class App(Generic[ReturnType], DOMNode):
self.driver_class = driver_class or self.get_driver_class() self.driver_class = driver_class or self.get_driver_class()
self._title = title self._title = title
self._screen_stack: list[Screen] = [] self._screen_stack: list[Screen] = []
self._terminal_features = TerminalSupportedFeatures.from_autodetect() self._terminal_features = TerminalSupportedFeatures()
self.focused: Widget | None = None self.focused: Widget | None = None
self.mouse_over: Widget | None = None self.mouse_over: Widget | None = None
@@ -860,18 +859,12 @@ class App(Generic[ReturnType], DOMNode):
""" """
if self._running and not self._closed: if self._running and not self._closed:
console = self.console console = self.console
( self._begin_update()
sync_update_start,
sync_update_end,
) = self._terminal_features.synchronized_update_sequences()
if sync_update_start:
console.file.write(sync_update_start)
try: try:
console.print(renderable) console.print(renderable)
except Exception as error: except Exception as error:
self.on_exception(error) self.on_exception(error)
if sync_update_end: self._end_update()
console.file.write(sync_update_end)
console.file.flush() console.file.flush()
def measure(self, renderable: RenderableType, max_width=100_000) -> int: def measure(self, renderable: RenderableType, max_width=100_000) -> int:
@@ -946,16 +939,6 @@ class App(Generic[ReturnType], DOMNode):
# Forward the event to the view # Forward the event to the view
await self.screen.forward_event(event) await self.screen.forward_event(event)
elif isinstance(event, ModeReport):
if event.mode is Mode.SynchronizedOutput:
is_supported = event.mode_is_supported
log(
f"SynchronizedOutput (aka 'mode2026') {'is' if is_supported else ' is not'} supported"
)
self._terminal_features = self._terminal_features._replace(
mode2026_synchronized_update=is_supported
)
else: else:
await super().on_event(event) await super().on_event(event)
@@ -1077,6 +1060,30 @@ class App(Generic[ReturnType], DOMNode):
async def handle_styles_updated(self, message: messages.StylesUpdated) -> None: async def handle_styles_updated(self, message: messages.StylesUpdated) -> None:
self.stylesheet.update(self, animate=True) self.stylesheet.update(self, animate=True)
def handle_mode_report_response(self, message: ModeReportResponse) -> None:
if message.mode is Mode.SynchronizedOutput:
is_supported = message.mode_is_supported
log(
f"SynchronizedOutput mode {'is' if is_supported else 'is not'} supported by this terminal"
)
self._terminal_features = self._terminal_features._replace(
synchronised_output=is_supported
)
def _begin_update(self) -> None:
synchronized_output_start_sequence = (
self._terminal_features.synchronized_output_start_sequence()
)
if synchronized_output_start_sequence:
self.console.file.write(synchronized_output_start_sequence)
def _end_update(self) -> None:
synchronized_output_end_sequence = (
self._terminal_features.synchronized_output_end_sequence()
)
if synchronized_output_end_sequence:
self.console.file.write(synchronized_output_end_sequence)
_uvloop_init_done: bool = False _uvloop_init_done: bool = False

View File

@@ -416,37 +416,3 @@ class DescendantFocus(Event, verbosity=2, bubble=True):
class DescendantBlur(Event, verbosity=2, bubble=True): class DescendantBlur(Event, verbosity=2, bubble=True):
pass pass
@rich.repr.auto
class ModeReport(InputEvent):
"""Sent when the terminals responses to a "mode "(i.e. a supported feature) request"""
__slots__ = ["mode", "report_parameter"]
def __init__(
self, sender: MessageTarget, mode: Mode, report_parameter: ModeReportParameter
) -> None:
"""
Args:
sender (MessageTarget): The sender of the event
mode (Mode): The mode the terminal emulator is giving us results about
report_parameter (ModeReportParameter): The status of this mode for the terminal emulator
"""
super().__init__(sender)
self.mode = mode
self.report_parameter = report_parameter
def __rich_repr__(self) -> rich.repr.Result:
yield "mode", self.mode
yield "report_parameter", self.report_parameter
@property
def mode_is_supported(self) -> bool:
"""Return True if the mode seems to be supported by the terminal emulator.
Returns:
bool: True if it's supported. False otherwise.
"""
return self.report_parameter in MODE_REPORTS_PARAMETERS_INDICATING_SUPPORT

View File

@@ -53,9 +53,7 @@ class AppTest(App):
# We need this so the `CLEAR_SCREEN_SEQUENCE` is always sent for a screen refresh, # We need this so the `CLEAR_SCREEN_SEQUENCE` is always sent for a screen refresh,
# whatever the environment: # whatever the environment:
# (we use it to slice the output into distinct full screens displays) # (we use it to slice the output into distinct full screens displays)
self._terminal_features = TerminalSupportedFeatures( self._terminal_features = TerminalSupportedFeatures(synchronised_output=True)
mode2026_synchronized_update=True,
)
self._size = size self._size = size
self._console = ConsoleTest(width=size.width, height=size.height) self._console = ConsoleTest(width=size.width, height=size.height)