mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into content
This commit is contained in:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed duplicated key displays in the help panel https://github.com/Textualize/textual/issues/5037
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for in-band terminal resize protocol https://github.com/Textualize/textual/pull/5217
|
||||
|
||||
### Changed
|
||||
|
||||
- `Driver.process_event` is now `Driver.process_message` https://github.com/Textualize/textual/pull/5217
|
||||
- `Driver.send_event` is now `Driver.send_message` https://github.com/Textualize/textual/pull/5217
|
||||
- Added `can_focus` and `can_focus_children` parameters to scrollable container types. https://github.com/Textualize/textual/pull/5226
|
||||
- Added `textual.lazy.Reveal` https://github.com/Textualize/textual/pull/5226
|
||||
- Added `Screen.action_blur` https://github.com/Textualize/textual/pull/5226
|
||||
|
||||
## [0.85.2] - 2024-11-02
|
||||
|
||||
- Fixed broken focus-within https://github.com/Textualize/textual/pull/5190
|
||||
|
||||
## [0.85.1] - 2024-10-26
|
||||
@@ -2493,6 +2511,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
|
||||
- New handler system for messages that doesn't require inheritance
|
||||
- Improved traceback handling
|
||||
|
||||
[0.85.2]: https://github.com/Textualize/textual/compare/v0.85.1...v0.85.2
|
||||
[0.85.1]: https://github.com/Textualize/textual/compare/v0.85.0...v0.85.1
|
||||
[0.85.0]: https://github.com/Textualize/textual/compare/v0.84.0...v0.85.0
|
||||
[0.84.0]: https://github.com/Textualize/textual/compare/v0.83.0...v0.84.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual"
|
||||
version = "0.85.1"
|
||||
version = "0.85.2"
|
||||
homepage = "https://github.com/Textualize/textual"
|
||||
repository = "https://github.com/Textualize/textual"
|
||||
documentation = "https://textual.textualize.io/"
|
||||
|
||||
@@ -15,7 +15,7 @@ from textual.message import Message
|
||||
# When trying to determine whether the current sequence is a supported/valid
|
||||
# escape sequence, at which length should we give up and consider our search
|
||||
# to be unsuccessful?
|
||||
_MAX_SEQUENCE_SEARCH_THRESHOLD = 20
|
||||
_MAX_SEQUENCE_SEARCH_THRESHOLD = 32
|
||||
|
||||
_re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z")
|
||||
_re_terminal_mode_response = re.compile(
|
||||
@@ -37,6 +37,9 @@ SPECIAL_SEQUENCES = {BRACKETED_PASTE_START, BRACKETED_PASTE_END, FOCUSIN, FOCUSO
|
||||
"""Set of special sequences."""
|
||||
|
||||
_re_extended_key: Final = re.compile(r"\x1b\[(?:(\d+)(?:;(\d+))?)?([u~ABCDEFHPQRS])")
|
||||
_re_in_band_window_resize: Final = re.compile(
|
||||
r"\x1b\[48;(\d+(?:\:.*?)?);(\d+(?:\:.*?)?);(\d+(?:\:.*?)?);(\d+(?:\:.*?)?)t"
|
||||
)
|
||||
|
||||
|
||||
class XTermParser(Parser[Message]):
|
||||
@@ -212,6 +215,16 @@ class XTermParser(Parser[Message]):
|
||||
elif sequence == BRACKETED_PASTE_END:
|
||||
bracketed_paste = False
|
||||
break
|
||||
if match := _re_in_band_window_resize.fullmatch(sequence):
|
||||
height, width, pixel_height, pixel_width = [
|
||||
group.partition(":")[0] for group in match.groups()
|
||||
]
|
||||
resize_event = events.Resize.from_dimensions(
|
||||
(int(width), int(height)),
|
||||
(int(pixel_width), int(pixel_height)),
|
||||
)
|
||||
on_token(resize_event)
|
||||
break
|
||||
|
||||
if not bracketed_paste:
|
||||
# Check cursor position report
|
||||
@@ -246,9 +259,14 @@ class XTermParser(Parser[Message]):
|
||||
mode_report_match = _re_terminal_mode_response.match(sequence)
|
||||
if mode_report_match is not None:
|
||||
mode_id = mode_report_match["mode_id"]
|
||||
setting_parameter = mode_report_match["setting_parameter"]
|
||||
if mode_id == "2026" and int(setting_parameter) > 0:
|
||||
setting_parameter = int(mode_report_match["setting_parameter"])
|
||||
if mode_id == "2026" and setting_parameter > 0:
|
||||
on_token(messages.TerminalSupportsSynchronizedOutput())
|
||||
elif mode_id == "2048":
|
||||
in_band_event = messages.TerminalSupportInBandWindowResize.from_setting_parameter(
|
||||
setting_parameter
|
||||
)
|
||||
on_token(in_band_event)
|
||||
break
|
||||
|
||||
if self._debug_log_file is not None:
|
||||
@@ -265,7 +283,7 @@ class XTermParser(Parser[Message]):
|
||||
Keys
|
||||
"""
|
||||
|
||||
if (match := _re_extended_key.match(sequence)) is not None:
|
||||
if (match := _re_extended_key.fullmatch(sequence)) is not None:
|
||||
number, modifiers, end = match.groups()
|
||||
number = number or 1
|
||||
if not (key := FUNCTIONAL_KEYS.get(f"{number}{end}", "")):
|
||||
|
||||
@@ -788,6 +788,12 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
self._hover_effects_timer: Timer | None = None
|
||||
|
||||
self._resize_event: events.Resize | None = None
|
||||
"""A pending resize event, sent on idle."""
|
||||
|
||||
self._css_update_count: int = 0
|
||||
"""Incremented when CSS is invalidated."""
|
||||
|
||||
if self.ENABLE_COMMAND_PALETTE:
|
||||
for _key, binding in self._bindings:
|
||||
if binding.action in {"command_palette", "app.command_palette"}:
|
||||
@@ -1191,6 +1197,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
variables = design.generate()
|
||||
return variables
|
||||
|
||||
def _invalidate_css(self) -> None:
|
||||
"""Invalidate CSS, so it will be refreshed."""
|
||||
self._css_update_count += 1
|
||||
|
||||
def watch_dark(self, dark: bool) -> None:
|
||||
"""Watches the dark bool.
|
||||
|
||||
@@ -1200,16 +1210,19 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.set_class(dark, "-dark-mode", update=False)
|
||||
self.set_class(not dark, "-light-mode", update=False)
|
||||
self._refresh_truecolor_filter(self.ansi_theme)
|
||||
self._invalidate_css()
|
||||
self.call_next(self.refresh_css)
|
||||
|
||||
def watch_ansi_theme_dark(self, theme: TerminalTheme) -> None:
|
||||
if self.dark:
|
||||
self._refresh_truecolor_filter(theme)
|
||||
self._invalidate_css()
|
||||
self.call_next(self.refresh_css)
|
||||
|
||||
def watch_ansi_theme_light(self, theme: TerminalTheme) -> None:
|
||||
if not self.dark:
|
||||
self._refresh_truecolor_filter(theme)
|
||||
self._invalidate_css()
|
||||
self.call_next(self.refresh_css)
|
||||
|
||||
@property
|
||||
@@ -1677,7 +1690,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
char = key if len(key) == 1 else None
|
||||
key_event = events.Key(key, char)
|
||||
key_event.set_sender(app)
|
||||
driver.send_event(key_event)
|
||||
driver.send_message(key_event)
|
||||
await wait_for_idle(0)
|
||||
await app._animator.wait_until_complete()
|
||||
await wait_for_idle(0)
|
||||
@@ -2205,7 +2218,9 @@ class App(Generic[ReturnType], DOMNode):
|
||||
screen, await_mount = self._get_screen(new_screen)
|
||||
stack.append(screen)
|
||||
self._load_screen_css(screen)
|
||||
self.refresh_css()
|
||||
if screen._css_update_count != self._css_update_count:
|
||||
self.refresh_css()
|
||||
|
||||
screen.post_message(events.ScreenResume())
|
||||
else:
|
||||
# Mode is not defined
|
||||
@@ -2250,6 +2265,8 @@ class App(Generic[ReturnType], DOMNode):
|
||||
await_mount = AwaitMount(self.screen, [])
|
||||
|
||||
self._current_mode = mode
|
||||
if self.screen._css_update_count != self._css_update_count:
|
||||
self.refresh_css()
|
||||
self.screen._screen_resized(self.size)
|
||||
self.screen.post_message(events.ScreenResume())
|
||||
|
||||
@@ -3366,6 +3383,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
stylesheet.update(self.app, animate=animate)
|
||||
try:
|
||||
self.screen._refresh_layout(self.size)
|
||||
self.screen._css_update_count = self._css_update_count
|
||||
except ScreenError:
|
||||
pass
|
||||
# The other screens in the stack will need to know about some style
|
||||
@@ -3374,6 +3392,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
for screen in self.screen_stack:
|
||||
if screen != self.screen:
|
||||
stylesheet.update(screen, animate=animate)
|
||||
screen._css_update_count = self._css_update_count
|
||||
|
||||
def _display(self, screen: Screen, renderable: RenderableType | None) -> None:
|
||||
"""Display a renderable within a sync.
|
||||
@@ -3680,7 +3699,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
raise ActionError(f"Action namespace {destination} is not known")
|
||||
action_target = getattr(self, destination, None)
|
||||
if action_target is None:
|
||||
raise ActionError("Action target {destination!r} not available")
|
||||
raise ActionError(f"Action target {destination!r} not available")
|
||||
return (
|
||||
(default_namespace if action_target is None else action_target),
|
||||
action_name,
|
||||
@@ -3827,9 +3846,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
async def _on_resize(self, event: events.Resize) -> None:
|
||||
event.stop()
|
||||
self.screen.post_message(event)
|
||||
for screen in self._background_screens:
|
||||
screen.post_message(event)
|
||||
self._resize_event = event
|
||||
|
||||
async def _on_app_focus(self, event: events.AppFocus) -> None:
|
||||
"""App has focus."""
|
||||
@@ -4459,3 +4476,21 @@ class App(Generic[ReturnType], DOMNode):
|
||||
self.notify(
|
||||
"Failed to save screenshot", title="Screenshot", severity="error"
|
||||
)
|
||||
|
||||
@on(messages.TerminalSupportInBandWindowResize)
|
||||
def _on_terminal_supports_in_band_window_resize(
|
||||
self, message: messages.TerminalSupportInBandWindowResize
|
||||
) -> None:
|
||||
"""There isn't much we can do with this information currently, so
|
||||
we will just log it.
|
||||
"""
|
||||
self.log.debug(message)
|
||||
|
||||
def _on_idle(self) -> None:
|
||||
"""Send app resize events on idle, so we don't do more resizing that necessary."""
|
||||
event = self._resize_event
|
||||
if event is not None:
|
||||
self._resize_event = None
|
||||
self.screen.post_message(event)
|
||||
for screen in self._background_screens:
|
||||
screen.post_message(event)
|
||||
|
||||
@@ -73,6 +73,40 @@ class ScrollableContainer(Widget, can_focus=True, inherit_bindings=False):
|
||||
| ctrl+pagedown | Scroll right one page, if horizontal scrolling is available. |
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*children: Widget,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
can_focus: bool | None = None,
|
||||
can_focus_children: bool | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
|
||||
Args:
|
||||
*children: Child widgets.
|
||||
name: The name of the widget.
|
||||
id: The ID of the widget in the DOM.
|
||||
classes: The CSS classes for the widget.
|
||||
disabled: Whether the widget is disabled or not.
|
||||
can_focus: Can this container be focused?
|
||||
can_focus_children: Can this container's children be focused?
|
||||
"""
|
||||
|
||||
super().__init__(
|
||||
*children,
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
disabled=disabled,
|
||||
)
|
||||
if can_focus is not None:
|
||||
self.can_focus = can_focus
|
||||
if can_focus_children is not None:
|
||||
self.can_focus_children = can_focus_children
|
||||
|
||||
|
||||
class Vertical(Widget, inherit_bindings=False):
|
||||
"""An expanding container with vertical layout and no scrollbars."""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from textual.app import App
|
||||
from textual.binding import Binding
|
||||
from textual.demo.home import HomeScreen
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from importlib.metadata import version
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from rich.syntax import Syntax
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from textual import events, on
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from math import sin
|
||||
@@ -6,7 +8,7 @@ from rich.syntax import Syntax
|
||||
from rich.table import Table
|
||||
from rich.traceback import Traceback
|
||||
|
||||
from textual import containers
|
||||
from textual import containers, lazy
|
||||
from textual.app import ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.demo.data import COUNTRIES
|
||||
@@ -439,19 +441,21 @@ class WidgetsScreen(PageScreen):
|
||||
CSS = """
|
||||
WidgetsScreen {
|
||||
align-horizontal: center;
|
||||
& > VerticalScroll > * {
|
||||
&:last-of-type { margin-bottom: 2; }
|
||||
&:even { background: $boost; }
|
||||
padding-bottom: 1;
|
||||
}
|
||||
& > VerticalScroll {
|
||||
scrollbar-gutter: stable;
|
||||
&> * {
|
||||
&:last-of-type { margin-bottom: 2; }
|
||||
&:even { background: $boost; }
|
||||
padding-bottom: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
BINDINGS = [Binding("escape", "unfocus", "Unfocus any focused widget", show=False)]
|
||||
BINDINGS = [Binding("escape", "blur", "Unfocus any focused widget", show=False)]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with containers.VerticalScroll() as container:
|
||||
container.can_focus = False
|
||||
with lazy.Reveal(containers.VerticalScroll(can_focus=False)):
|
||||
yield Markdown(WIDGETS_MD, classes="column")
|
||||
yield Buttons()
|
||||
yield Checkboxes()
|
||||
@@ -461,6 +465,3 @@ class WidgetsScreen(PageScreen):
|
||||
yield Logs()
|
||||
yield Sparklines()
|
||||
yield Footer()
|
||||
|
||||
def action_unfocus(self) -> None:
|
||||
self.set_focus(None)
|
||||
|
||||
@@ -7,7 +7,7 @@ from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, BinaryIO, Iterator, Literal, TextIO
|
||||
|
||||
from textual import events, log
|
||||
from textual import events, log, messages
|
||||
from textual.events import MouseUp
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -64,46 +64,46 @@ class Driver(ABC):
|
||||
"""Can this driver be suspended?"""
|
||||
return False
|
||||
|
||||
def send_event(self, event: events.Event) -> None:
|
||||
"""Send an event to the target app.
|
||||
def send_message(self, message: messages.Message) -> None:
|
||||
"""Send a message to the target app.
|
||||
|
||||
Args:
|
||||
event: An event.
|
||||
message: A message.
|
||||
"""
|
||||
asyncio.run_coroutine_threadsafe(
|
||||
self._app._post_message(event), loop=self._loop
|
||||
self._app._post_message(message), loop=self._loop
|
||||
)
|
||||
|
||||
def process_event(self, event: events.Event) -> None:
|
||||
"""Perform additional processing on an event, prior to sending.
|
||||
def process_message(self, message: messages.Message) -> None:
|
||||
"""Perform additional processing on a message, prior to sending.
|
||||
|
||||
Args:
|
||||
event: An event to send.
|
||||
event: A message to process.
|
||||
"""
|
||||
# NOTE: This runs in a thread.
|
||||
# Avoid calling methods on the app.
|
||||
event.set_sender(self._app)
|
||||
message.set_sender(self._app)
|
||||
if self.cursor_origin is None:
|
||||
offset_x = 0
|
||||
offset_y = 0
|
||||
else:
|
||||
offset_x, offset_y = self.cursor_origin
|
||||
if isinstance(event, events.MouseEvent):
|
||||
event.x -= offset_x
|
||||
event.y -= offset_y
|
||||
event.screen_x -= offset_x
|
||||
event.screen_y -= offset_y
|
||||
if isinstance(message, events.MouseEvent):
|
||||
message.x -= offset_x
|
||||
message.y -= offset_y
|
||||
message.screen_x -= offset_x
|
||||
message.screen_y -= offset_y
|
||||
|
||||
if isinstance(event, events.MouseDown):
|
||||
if event.button:
|
||||
self._down_buttons.append(event.button)
|
||||
elif isinstance(event, events.MouseUp):
|
||||
if event.button and event.button in self._down_buttons:
|
||||
self._down_buttons.remove(event.button)
|
||||
elif isinstance(event, events.MouseMove):
|
||||
if isinstance(message, events.MouseDown):
|
||||
if message.button:
|
||||
self._down_buttons.append(message.button)
|
||||
elif isinstance(message, events.MouseUp):
|
||||
if message.button and message.button in self._down_buttons:
|
||||
self._down_buttons.remove(message.button)
|
||||
elif isinstance(message, events.MouseMove):
|
||||
if (
|
||||
self._down_buttons
|
||||
and not event.button
|
||||
and not message.button
|
||||
and self._last_move_event is not None
|
||||
):
|
||||
# Deduplicate self._down_buttons while preserving order.
|
||||
@@ -111,24 +111,24 @@ class Driver(ABC):
|
||||
self._down_buttons.clear()
|
||||
move_event = self._last_move_event
|
||||
for button in buttons:
|
||||
self.send_event(
|
||||
self.send_message(
|
||||
MouseUp(
|
||||
x=move_event.x,
|
||||
y=move_event.y,
|
||||
delta_x=0,
|
||||
delta_y=0,
|
||||
button=button,
|
||||
shift=event.shift,
|
||||
meta=event.meta,
|
||||
ctrl=event.ctrl,
|
||||
shift=message.shift,
|
||||
meta=message.meta,
|
||||
ctrl=message.ctrl,
|
||||
screen_x=move_event.screen_x,
|
||||
screen_y=move_event.screen_y,
|
||||
style=event.style,
|
||||
style=message.style,
|
||||
)
|
||||
)
|
||||
self._last_move_event = event
|
||||
self._last_move_event = message
|
||||
|
||||
self.send_event(event)
|
||||
self.send_message(message)
|
||||
|
||||
@abstractmethod
|
||||
def write(self, data: str) -> None:
|
||||
|
||||
@@ -20,6 +20,8 @@ from textual._xterm_parser import XTermParser
|
||||
from textual.driver import Driver
|
||||
from textual.drivers._writer_thread import WriterThread
|
||||
from textual.geometry import Size
|
||||
from textual.message import Message
|
||||
from textual.messages import TerminalSupportInBandWindowResize
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from textual.app import App
|
||||
@@ -59,6 +61,7 @@ class LinuxDriver(Driver):
|
||||
# need to know that we came in here via a SIGTSTP; this flag helps
|
||||
# keep track of this.
|
||||
self._must_signal_resume = False
|
||||
self._in_band_window_resize = False
|
||||
|
||||
# Put handlers for SIGTSTP and SIGCONT in place. These are necessary
|
||||
# to support the user pressing Ctrl+Z (or whatever the dev might
|
||||
@@ -135,6 +138,22 @@ class LinuxDriver(Driver):
|
||||
"""Enable bracketed paste mode."""
|
||||
self.write("\x1b[?2004h")
|
||||
|
||||
def _query_in_band_window_resize(self) -> None:
|
||||
self.write("\x1b[?2048$p")
|
||||
|
||||
def _enable_in_band_window_resize(self) -> None:
|
||||
self.write("\x1b[?2048h")
|
||||
|
||||
def _enable_line_wrap(self) -> None:
|
||||
self.write("\x1b[?7h")
|
||||
|
||||
def _disable_line_wrap(self) -> None:
|
||||
self.write("\x1b[?7l")
|
||||
|
||||
def _disable_in_band_window_resize(self) -> None:
|
||||
if self._in_band_window_resize:
|
||||
self.write("\x1b[?2048l")
|
||||
|
||||
def _disable_bracketed_paste(self) -> None:
|
||||
"""Disable bracketed paste mode."""
|
||||
self.write("\x1b[?2004l")
|
||||
@@ -197,6 +216,8 @@ class LinuxDriver(Driver):
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def send_size_event() -> None:
|
||||
if self._in_band_window_resize:
|
||||
return
|
||||
terminal_size = self._get_terminal_size()
|
||||
width, height = terminal_size
|
||||
textual_size = Size(width, height)
|
||||
@@ -253,7 +274,9 @@ class LinuxDriver(Driver):
|
||||
send_size_event()
|
||||
self._key_thread.start()
|
||||
self._request_terminal_sync_mode_support()
|
||||
self._query_in_band_window_resize()
|
||||
self._enable_bracketed_paste()
|
||||
self._disable_line_wrap()
|
||||
|
||||
# Appears to fix an issue enabling mouse support in iTerm 3.5.0
|
||||
self._enable_mouse_support()
|
||||
@@ -330,6 +353,8 @@ class LinuxDriver(Driver):
|
||||
def stop_application_mode(self) -> None:
|
||||
"""Stop application mode, restore state."""
|
||||
self._disable_bracketed_paste()
|
||||
self._enable_line_wrap()
|
||||
self._disable_in_band_window_resize()
|
||||
self.disable_input()
|
||||
|
||||
if self.attrs_before is not None:
|
||||
@@ -401,9 +426,9 @@ class LinuxDriver(Driver):
|
||||
# This can occur if the stdin is piped
|
||||
break
|
||||
for event in feed(unicode_data):
|
||||
self.process_event(event)
|
||||
self.process_message(event)
|
||||
for event in tick():
|
||||
self.process_event(event)
|
||||
self.process_message(event)
|
||||
|
||||
try:
|
||||
while not self.exit_event.is_set():
|
||||
@@ -418,3 +443,22 @@ class LinuxDriver(Driver):
|
||||
pass
|
||||
except ParseError:
|
||||
pass
|
||||
|
||||
def process_message(self, message: Message) -> None:
|
||||
# intercept in-band window resize
|
||||
if isinstance(message, TerminalSupportInBandWindowResize):
|
||||
# If it is supported, enabled it
|
||||
if message.supported and not message.enabled:
|
||||
self._enable_in_band_window_resize()
|
||||
self._in_band_window_resize = message.supported
|
||||
elif message.enabled:
|
||||
self._in_band_window_resize = message.supported
|
||||
# Send up-to-date message
|
||||
super().process_message(
|
||||
TerminalSupportInBandWindowResize(
|
||||
message.supported, self._in_band_window_resize
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
super().process_message(message)
|
||||
|
||||
@@ -155,12 +155,12 @@ class LinuxInlineDriver(Driver):
|
||||
if isinstance(event, events.CursorPosition):
|
||||
self.cursor_origin = (event.x, event.y)
|
||||
else:
|
||||
self.process_event(event)
|
||||
self.process_message(event)
|
||||
for event in tick():
|
||||
if isinstance(event, events.CursorPosition):
|
||||
self.cursor_origin = (event.x, event.y)
|
||||
else:
|
||||
self.process_event(event)
|
||||
self.process_message(event)
|
||||
|
||||
try:
|
||||
while not self.exit_event.is_set():
|
||||
|
||||
@@ -195,12 +195,12 @@ class WebDriver(Driver):
|
||||
if packet_type == "D":
|
||||
# Treat as stdin
|
||||
for event in parser.feed(decode(payload)):
|
||||
self.process_event(event)
|
||||
self.process_message(event)
|
||||
else:
|
||||
# Process meta information separately
|
||||
self._on_meta(packet_type, payload)
|
||||
for event in parser.tick():
|
||||
self.process_event(event)
|
||||
self.process_message(event)
|
||||
except _ExitInput:
|
||||
pass
|
||||
except Exception:
|
||||
|
||||
@@ -101,7 +101,7 @@ class WindowsDriver(Driver):
|
||||
self._enable_bracketed_paste()
|
||||
|
||||
self._event_thread = win32.EventMonitor(
|
||||
loop, self._app, self.exit_event, self.process_event
|
||||
loop, self._app, self.exit_event, self.process_message
|
||||
)
|
||||
self._event_thread.start()
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ class Resize(Event, bubble=False):
|
||||
size: Size,
|
||||
virtual_size: Size,
|
||||
container_size: Size | None = None,
|
||||
pixel_size: Size | None = None,
|
||||
) -> None:
|
||||
self.size = size
|
||||
"""The new size of the Widget."""
|
||||
@@ -122,15 +123,33 @@ class Resize(Event, bubble=False):
|
||||
"""The virtual size (scrollable size) of the Widget."""
|
||||
self.container_size = size if container_size is None else container_size
|
||||
"""The size of the Widget's container widget."""
|
||||
self.pixel_size = pixel_size
|
||||
"""Size of terminal window in pixels if known, or `None` if not known."""
|
||||
super().__init__()
|
||||
|
||||
@classmethod
|
||||
def from_dimensions(
|
||||
cls, cells: tuple[int, int], pixels: tuple[int, int] | None
|
||||
) -> Resize:
|
||||
"""Construct from basic dimensions.
|
||||
|
||||
Args:
|
||||
cells: tuple of (<width>, <height>) in cells.
|
||||
pixels: tuple of (<width>, <height>) in pixels if known, or `None` if not known.
|
||||
|
||||
"""
|
||||
size = Size(*cells)
|
||||
pixel_size = Size(*pixels) if pixels is not None else None
|
||||
return Resize(size, size, size, pixel_size)
|
||||
|
||||
def can_replace(self, message: "Message") -> bool:
|
||||
return isinstance(message, Resize)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "size", self.size
|
||||
yield "virtual_size", self.virtual_size
|
||||
yield "virtual_size", self.virtual_size, self.size
|
||||
yield "container_size", self.container_size, self.size
|
||||
yield "pixel_size", self.pixel_size, None
|
||||
|
||||
|
||||
class Compose(Event, bubble=False, verbose=True):
|
||||
|
||||
@@ -4,6 +4,8 @@ Tools for lazy loading widgets.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import partial
|
||||
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
@@ -61,3 +63,85 @@ class Lazy(Widget):
|
||||
await self.remove()
|
||||
|
||||
self.call_after_refresh(mount)
|
||||
|
||||
|
||||
class Reveal(Widget):
|
||||
DEFAULT_CSS = """
|
||||
Reveal {
|
||||
display: none;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, widget: Widget, delay: float = 1 / 60) -> None:
|
||||
"""Similar to [Lazy][textual.lazy.Lazy], but also displays *children* sequentially.
|
||||
|
||||
The first frame will display the first child with all other children hidden.
|
||||
The remaining children will be displayed 1-by-1, over as may frames are required.
|
||||
|
||||
This is useful when you have so many child widgets that there is a noticeable delay before
|
||||
you see anything. By mounting the children over several frames, the user will feel that
|
||||
something is happening.
|
||||
|
||||
Example:
|
||||
```python
|
||||
def compose(self) -> ComposeResult:
|
||||
with lazy.Reveal(containers.VerticalScroll(can_focus=False)):
|
||||
yield Markdown(WIDGETS_MD, classes="column")
|
||||
yield Buttons()
|
||||
yield Checkboxes()
|
||||
yield Datatables()
|
||||
yield Inputs()
|
||||
yield ListViews()
|
||||
yield Logs()
|
||||
yield Sparklines()
|
||||
yield Footer()
|
||||
```
|
||||
|
||||
Args:
|
||||
widget: A widget that should be mounted after a refresh.
|
||||
delay: A (short) delay between mounting widgets.
|
||||
"""
|
||||
self._replace_widget = widget
|
||||
self._delay = delay
|
||||
super().__init__()
|
||||
|
||||
@classmethod
|
||||
def _reveal(cls, parent: Widget, delay: float = 1 / 60) -> None:
|
||||
"""Reveal children lazily.
|
||||
|
||||
Args:
|
||||
parent: The parent widget.
|
||||
delay: A delay between reveals.
|
||||
"""
|
||||
|
||||
def check_children() -> None:
|
||||
"""Check for un-displayed children."""
|
||||
iter_children = iter(parent.children)
|
||||
for child in iter_children:
|
||||
if not child.display:
|
||||
child.display = True
|
||||
break
|
||||
for child in iter_children:
|
||||
if not child.display:
|
||||
parent.set_timer(
|
||||
delay, partial(parent.call_after_refresh, check_children)
|
||||
)
|
||||
break
|
||||
|
||||
check_children()
|
||||
|
||||
def compose_add_child(self, widget: Widget) -> None:
|
||||
widget.display = False
|
||||
self._replace_widget.compose_add_child(widget)
|
||||
|
||||
async def mount_composed_widgets(self, widgets: list[Widget]) -> None:
|
||||
parent = self.parent
|
||||
if parent is None:
|
||||
return
|
||||
assert isinstance(parent, Widget)
|
||||
|
||||
if self._replace_widget.children:
|
||||
self._replace_widget.children[0].display = True
|
||||
await parent.mount(self._replace_widget, after=self)
|
||||
await self.remove()
|
||||
self._reveal(self._replace_widget, self._delay)
|
||||
|
||||
@@ -97,3 +97,42 @@ class TerminalSupportsSynchronizedOutput(Message):
|
||||
Used to make the App aware that the terminal emulator supports synchronised output.
|
||||
@link https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036
|
||||
"""
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class TerminalSupportInBandWindowResize(Message):
|
||||
"""Reports if the in-band window resize protocol is supported.
|
||||
|
||||
https://gist.github.com/rockorager/e695fb2924d36b2bcf1fff4a3704bd83"""
|
||||
|
||||
def __init__(self, supported: bool, enabled: bool) -> None:
|
||||
"""Initialize message.
|
||||
|
||||
Args:
|
||||
supported: Is the protocol supported?
|
||||
enabled: Is the protocol enabled.
|
||||
"""
|
||||
self.supported = supported
|
||||
self.enabled = enabled
|
||||
super().__init__()
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "supported", self.supported
|
||||
yield "enabled", self.enabled
|
||||
|
||||
@classmethod
|
||||
def from_setting_parameter(
|
||||
cls, setting_parameter: int
|
||||
) -> TerminalSupportInBandWindowResize:
|
||||
"""Construct the message from the setting parameter.
|
||||
|
||||
Args:
|
||||
setting_parameter: Setting parameter from stdin.
|
||||
|
||||
Returns:
|
||||
New TerminalSupportInBandWindowResize instance.
|
||||
"""
|
||||
|
||||
supported = setting_parameter not in (0, 4)
|
||||
enabled = setting_parameter in (1, 3)
|
||||
return TerminalSupportInBandWindowResize(supported, enabled)
|
||||
|
||||
@@ -267,6 +267,9 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
self.bindings_updated_signal: Signal[Screen] = Signal(self, "bindings_updated")
|
||||
"""A signal published when the bindings have been updated"""
|
||||
|
||||
self._css_update_count = -1
|
||||
"""Track updates to CSS."""
|
||||
|
||||
@property
|
||||
def is_modal(self) -> bool:
|
||||
"""Is the screen modal?"""
|
||||
@@ -780,6 +783,10 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
"""Action to minimize the currently maximized widget."""
|
||||
self.minimize()
|
||||
|
||||
def action_blur(self) -> None:
|
||||
"""Action to remove focus (if set)."""
|
||||
self.set_focus(None)
|
||||
|
||||
def _reset_focus(
|
||||
self, widget: Widget, avoiding: list[Widget] | None = None
|
||||
) -> None:
|
||||
|
||||
@@ -90,11 +90,13 @@ class BindingsTable(Static):
|
||||
get_key_display = self.app.get_key_display
|
||||
for multi_bindings in action_to_bindings.values():
|
||||
binding, enabled, tooltip = multi_bindings[0]
|
||||
key_display = " ".join(
|
||||
get_key_display(binding) for binding, _, _ in multi_bindings
|
||||
keys_display = " ".join(
|
||||
dict.fromkeys( # Remove duplicates while preserving order
|
||||
get_key_display(binding) for binding, _, _ in multi_bindings
|
||||
)
|
||||
)
|
||||
table.add_row(
|
||||
Text(key_display, style=key_style),
|
||||
Text(keys_display, style=key_style),
|
||||
render_description(binding),
|
||||
)
|
||||
if namespace != previous_namespace:
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 28 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 28 KiB |
@@ -2347,7 +2347,7 @@ def test_fr_and_margin(snap_compare):
|
||||
|
||||
class FRApp(App):
|
||||
CSS = """
|
||||
#first-container {
|
||||
#first-container {
|
||||
background: green;
|
||||
height: auto;
|
||||
}
|
||||
@@ -2355,7 +2355,7 @@ def test_fr_and_margin(snap_compare):
|
||||
#second-container {
|
||||
margin: 2;
|
||||
background: red;
|
||||
height: auto;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#third-container {
|
||||
@@ -2416,3 +2416,21 @@ def test_split_segments_infinite_loop(snap_compare):
|
||||
|
||||
"""
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "split_segments.py")
|
||||
|
||||
|
||||
def test_help_panel_key_display_not_duplicated(snap_compare):
|
||||
"""Regression test for https://github.com/Textualize/textual/issues/5037"""
|
||||
|
||||
class HelpPanelApp(App):
|
||||
BINDINGS = [
|
||||
Binding("b,e,l", "bell", "Ring the bell", key_display="foo"),
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Footer()
|
||||
|
||||
async def run_before(pilot: Pilot):
|
||||
pilot.app.action_show_help_panel()
|
||||
|
||||
app = HelpPanelApp()
|
||||
assert snap_compare(app, run_before=run_before)
|
||||
|
||||
10
tests/test_demo.py
Normal file
10
tests/test_demo.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from textual.demo.demo_app import DemoApp
|
||||
|
||||
|
||||
async def test_demo():
|
||||
"""Test the demo runs."""
|
||||
# Test he demo can at least run.
|
||||
# This exists mainly to catch screw-ups that might effect only certain Python versions.
|
||||
app = DemoApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause(0.1)
|
||||
@@ -18,8 +18,8 @@ async def test_driver_mouse_down_up_click():
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test() as pilot:
|
||||
app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_event(MouseUp(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_message(MouseDown(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_message(MouseUp(0, 0, 0, 0, 1, False, False, False))
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 3
|
||||
assert isinstance(app.messages[0], MouseDown)
|
||||
@@ -41,8 +41,8 @@ async def test_driver_mouse_down_up_click_widget():
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test() as pilot:
|
||||
app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_event(MouseUp(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_message(MouseDown(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_message(MouseUp(0, 0, 0, 0, 1, False, False, False))
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
|
||||
@@ -69,8 +69,8 @@ async def test_driver_mouse_down_drag_inside_widget_up_click():
|
||||
assert (width, height) == (button_width, button_height)
|
||||
|
||||
# Mouse down on the button, then move the mouse inside the button, then mouse up.
|
||||
app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_event(
|
||||
app._driver.process_message(MouseDown(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_message(
|
||||
MouseUp(
|
||||
button_width - 1,
|
||||
button_height - 1,
|
||||
@@ -108,8 +108,8 @@ async def test_driver_mouse_down_drag_outside_widget_up_click():
|
||||
assert (width, height) == (button_width, button_height)
|
||||
|
||||
# Mouse down on the button, then move the mouse outside the button, then mouse up.
|
||||
app._driver.process_event(MouseDown(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_event(
|
||||
app._driver.process_message(MouseDown(0, 0, 0, 0, 1, False, False, False))
|
||||
app._driver.process_message(
|
||||
MouseUp(
|
||||
button_width + 1,
|
||||
button_height + 1,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.lazy import Lazy
|
||||
from textual.lazy import Lazy, Reveal
|
||||
from textual.widgets import Label
|
||||
|
||||
|
||||
@@ -24,3 +24,32 @@ async def test_lazy():
|
||||
# #bar mounted after refresh
|
||||
assert len(app.query("#foo")) == 1
|
||||
assert len(app.query("#bar")) == 1
|
||||
|
||||
|
||||
class RevealApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
with Reveal(Vertical()):
|
||||
yield Label(id="foo")
|
||||
yield Label(id="bar")
|
||||
yield Label(id="baz")
|
||||
|
||||
|
||||
async def test_lazy_reveal():
|
||||
app = RevealApp()
|
||||
async with app.run_test() as pilot:
|
||||
# No #foo on initial mount
|
||||
|
||||
# Only first child should be visible initially
|
||||
assert app.query_one("#foo").display
|
||||
assert not app.query_one("#bar").display
|
||||
assert not app.query_one("#baz").display
|
||||
|
||||
# All children should be visible after a pause
|
||||
await pilot.pause()
|
||||
for n in range(3):
|
||||
await pilot.pause(1 / 60)
|
||||
await pilot.pause()
|
||||
|
||||
assert app.query_one("#foo").display
|
||||
assert app.query_one("#bar").display
|
||||
assert app.query_one("#baz").display
|
||||
|
||||
@@ -91,7 +91,7 @@ def test_cant_match_escape_sequence_too_long(parser):
|
||||
"""The sequence did not match, and we hit the maximum sequence search
|
||||
length threshold, so each character should be issued as a key-press instead.
|
||||
"""
|
||||
sequence = "\x1b[123456789123456789123"
|
||||
sequence = "\x1b[123456789123456789123123456789123456789123"
|
||||
events = list(parser.feed(sequence))
|
||||
|
||||
# Every character in the sequence is converted to a key press
|
||||
|
||||
Reference in New Issue
Block a user