Merge branch 'main' into content

This commit is contained in:
Will McGugan
2024-11-12 12:08:00 +00:00
27 changed files with 664 additions and 139 deletions

View File

@@ -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

View File

@@ -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/"

View File

@@ -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}", "")):

View File

@@ -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)

View File

@@ -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."""

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from textual.app import App
from textual.binding import Binding
from textual.demo.home import HomeScreen

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import asyncio
from importlib.metadata import version

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import inspect
from rich.syntax import Syntax

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
from dataclasses import dataclass
from textual import events, on

View File

@@ -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)

View File

@@ -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:

View File

@@ -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)

View File

@@ -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():

View File

@@ -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:

View File

@@ -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()

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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
View 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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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