Merge branch 'main' into toggle-boxen

This commit is contained in:
Dave Pearson
2023-02-22 10:30:50 +00:00
10 changed files with 108 additions and 62 deletions

View File

@@ -24,11 +24,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Removed `screen.visible_widgets` and `screen.widgets`
### Fixed
- Numbers in a descendant-combined selector no longer cause an error https://github.com/Textualize/textual/issues/1836
- Fixed superfluous scrolling when focusing a docked widget https://github.com/Textualize/textual/issues/1816
## [0.11.1] - 2023-02-17

14
FAQ.md
View File

@@ -4,6 +4,7 @@
# Frequently Asked Questions
- [Does Textual support images?](#does-textual-support-images)
- [How can I fix ImportError cannot import name ComposeResult from textual.app ?](#how-can-i-fix-importerror-cannot-import-name-composeresult-from-textualapp-)
- [How can I select and copy text in a Textual app?](#how-can-i-select-and-copy-text-in-a-textual-app)
- [How do I center a widget in a screen?](#how-do-i-center-a-widget-in-a-screen)
- [How do I pass arguments to an app?](#how-do-i-pass-arguments-to-an-app)
- [Why doesn't Textual support ANSI themes?](#why-doesn't-textual-support-ansi-themes)
@@ -26,6 +27,19 @@ The following should do it:
pip install "textual[dev]" -U
```
<a name="how-can-i-select-and-copy-text-in-a-textual-app"></a>
## How can I select and copy text in a Textual app?
Running a Textual app puts your terminal in to *application mode* which disables clicking and dragging to select text.
Most terminal emulators offer a modifier key which you can hold while you click and drag to restore the behavior you
may expect from the command line. The exact modifier key depends on the terminal and platform you are running on.
- **iTerm** Hold the OPTION key.
- **Gnome Terminal** Hold the SHIFT key.
- **Windows Terminal** Hold the SHIFT key.
Refer to the documentation for your terminal emulator, if it is not listed above.
<a name="how-do-i-center-a-widget-in-a-screen"></a>
## How do I center a widget in a screen?

View File

@@ -21,7 +21,7 @@ class DictionaryApp(App):
def compose(self) -> ComposeResult:
yield Input(placeholder="Search for a word")
with Content(id="results-container"):
yield Static(id="results")
yield Markdown(id="results")
def on_mount(self) -> None:
"""Called when app starts."""

View File

@@ -87,11 +87,10 @@ class GameHeader(Widget):
Returns:
ComposeResult: The result of composing the game header.
"""
yield Horizontal(
Label(self.app.title, id="app-title"),
Label(id="moves"),
Label(id="progress"),
)
with Horizontal():
yield Label(self.app.title, id="app-title")
yield Label(id="moves")
yield Label(id="progress")
def watch_moves(self, moves: int):
"""Watch the moves reactive and update when it changes.

View File

@@ -0,0 +1,16 @@
---
title: "How can I select and copy text in a Textual app?"
alt_titles:
- "Can't copy text"
- "Highlighting and copy text not working"
---
Running a Textual app puts your terminal in to *application mode* which disables clicking and dragging to select text.
Most terminal emulators offer a modifier key which you can hold while you click and drag to restore the behavior you
may expect from the command line. The exact modifier key depends on the terminal and platform you are running on.
- **iTerm** Hold the OPTION key.
- **Gnome Terminal** Hold the SHIFT key.
- **Windows Terminal** Hold the SHIFT key.
Refer to the documentation for your terminal emulator, if it is not listed above.

View File

@@ -166,12 +166,14 @@ class Compositor:
def __init__(self) -> None:
# A mapping of Widget on to its "render location" (absolute position / depth)
self.map: CompositorMap = {}
self._full_map: CompositorMap | None = None
self._full_map: CompositorMap = {}
self._full_map_invalidated = True
self._visible_map: CompositorMap | None = None
self._layers: list[tuple[Widget, MapGeometry]] | None = None
# All widgets considered in the arrangement
# Note this may be a superset of self.map.keys() as some widgets may be invisible for various reasons
# Note this may be a superset of self.full_map.keys() as some widgets may be invisible for various reasons
self.widgets: set[Widget] = set()
# Mapping of visible widgets on to their region, and clip region
@@ -248,12 +250,12 @@ class Compositor:
self._layers = None
self._layers_visible = None
self._visible_widgets = None
self._full_map = None
self._visible_map = None
self.root = parent
self.size = size
# Keep a copy of the old map because we're going to compare it with the update
old_map = self.map
old_map = self._full_map
old_widgets = old_map.keys()
map, widgets = self._arrange_root(parent, size)
@@ -261,7 +263,6 @@ class Compositor:
new_widgets = map.keys()
# Replace map and widgets
self.map = map
self._full_map = map
self.widgets = widgets
@@ -316,19 +317,22 @@ class Compositor:
self._layers = None
self._layers_visible = None
self._visible_widgets = None
self._full_map = None
self._full_map_invalidated = True
self.root = parent
self.size = size
# Keep a copy of the old map because we're going to compare it with the update
old_map = self.map
old_map = (
self._visible_map if self._visible_map is not None else self._full_map or {}
)
map, widgets = self._arrange_root(parent, size, visible_only=True)
exposed_widgets = map.keys() - old_map.keys()
# Replace map and widgets
self.map = map
self._visible_map = map
self.widgets = widgets
exposed_widgets = map.keys() - old_map.keys()
# Contains widgets + geometry for every widget that changed (added, removed, or updated)
changes = map.items() ^ old_map.items()
@@ -350,11 +354,15 @@ class Compositor:
@property
def full_map(self) -> CompositorMap:
"""Lazily built compositor map that covers all widgets."""
if self.root is None or not self.map:
if self.root is None:
return {}
if self._full_map is None:
if self._full_map_invalidated:
self._full_map_invalidated = False
map, widgets = self._arrange_root(self.root, self.size, visible_only=False)
self._full_map = map
self._visible_widgets = None
self._visible_map = None
return self._full_map
@@ -365,7 +373,13 @@ class Compositor:
Returns:
Visible widget mapping.
"""
if self._visible_widgets is None:
map = (
self._visible_map
if self._visible_map is not None
else (self._full_map or {})
)
screen = self.size.region
in_screen = screen.overlaps
overlaps = Region.overlaps
@@ -373,7 +387,7 @@ class Compositor:
# Widgets and regions in render order
visible_widgets = [
(order, widget, region, clip)
for widget, (region, order, clip, _, _, _) in self.map.items()
for widget, (region, order, clip, _, _, _) in map.items()
if in_screen(region) and overlaps(clip, region)
]
visible_widgets.sort(key=itemgetter(0), reverse=True)
@@ -556,9 +570,10 @@ class Compositor:
@property
def layers(self) -> list[tuple[Widget, MapGeometry]]:
"""Get widgets and geometry in layer order."""
map = self._visible_map if self._visible_map is not None else self._full_map
if self._layers is None:
self._layers = sorted(
self.map.items(), key=lambda item: item[1].order, reverse=True
map.items(), key=lambda item: item[1].order, reverse=True
)
return self._layers
@@ -585,12 +600,14 @@ class Compositor:
def get_offset(self, widget: Widget) -> Offset:
"""Get the offset of a widget."""
try:
return self.map[widget].region.offset
if self._visible_map is not None:
try:
return self._visible_map[widget].region.offset
except KeyError:
pass
return self.full_map[widget].region.offset
except KeyError:
try:
return self.full_map[widget].region.offset
except KeyError:
raise errors.NoWidget("Widget is not in layout")
raise errors.NoWidget("Widget is not in layout")
def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
"""Get the widget under a given coordinate.
@@ -672,15 +689,17 @@ class Compositor:
Widget's composition information.
"""
if self.root is None or not self.map:
if self.root is None:
raise errors.NoWidget("Widget is not in layout")
try:
region = self.map[widget]
if self._visible_map is not None:
try:
return self._visible_map[widget]
except KeyError:
pass
region = self.full_map[widget]
except KeyError:
try:
return self.full_map[widget]
except KeyError:
raise errors.NoWidget("Widget is not in layout")
raise errors.NoWidget("Widget is not in layout")
else:
return region
@@ -728,9 +747,6 @@ class Compositor:
# up to this point.
_rich_traceback_guard = True
if not self.map:
return
_Region = Region
visible_widgets = self.visible_widgets
@@ -864,7 +880,10 @@ class Compositor:
widget: Widget to update.
"""
self._full_map = None
if not self._full_map_invalidated and not widgets.issuperset(
self.visible_widgets
):
self._full_map_invalidated = True
regions: list[Region] = []
add_region = regions.append
get_widget = self.visible_widgets.__getitem__

View File

@@ -425,12 +425,12 @@ class Screen(Widget):
self._compositor.update_widgets(self._dirty_widgets)
self.update_timer.pause()
ResizeEvent = events.Resize
try:
if scroll:
exposed_widgets = self._compositor.reflow_visible(self, size)
if exposed_widgets:
layers = self._compositor.layers
for widget, (
region,
_order,
@@ -441,19 +441,14 @@ class Screen(Widget):
) in layers:
if widget in exposed_widgets:
if widget._size_updated(
region.size,
virtual_size,
container_size,
layout=False,
region.size, virtual_size, container_size, layout=False
):
widget.post_message_no_wait(
ResizeEvent(
self,
region.size,
virtual_size,
container_size,
self, region.size, virtual_size, container_size
)
)
else:
hidden, shown, resized = self._compositor.reflow(self, size)
Hide = events.Hide

View File

@@ -40,8 +40,8 @@ from . import errors, events, messages
from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
from ._arrange import DockArrangeResult, arrange
from ._asyncio import create_task
from ._compose import compose
from ._cache import FIFOCache
from ._compose import compose
from ._context import active_app
from ._easing import DEFAULT_SCROLL_EASING
from ._layout import Layout
@@ -1891,18 +1891,22 @@ class Widget(DOMNode):
while isinstance(widget.parent, Widget) and widget is not self:
container = widget.parent
scroll_offset = container.scroll_to_region(
region,
spacing=widget.parent.gutter,
animate=animate,
speed=speed,
duration=duration,
top=top,
easing=easing,
force=force,
)
if scroll_offset:
scrolled = True
if widget.styles.dock:
scroll_offset = Offset(0, 0)
else:
scroll_offset = container.scroll_to_region(
region,
spacing=widget.parent.gutter,
animate=animate,
speed=speed,
duration=duration,
top=top,
easing=easing,
force=force,
)
if scroll_offset:
scrolled = True
# Adjust the region by the amount we just scrolled it, and convert to
# it's parent's virtual coordinate system.

View File

@@ -145,7 +145,7 @@ class Button(Static, can_focus=True):
ACTIVE_EFFECT_DURATION = 0.3
"""When buttons are clicked they get the `-active` class for this duration (in seconds)"""
label: reactive[RenderableType] = reactive("")
label: reactive[RenderableType] = reactive[RenderableType]("")
"""The text label that appears within the button."""
variant = reactive("default")

View File

@@ -72,7 +72,7 @@ class Placeholder(Widget):
_COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
_SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]"
variant: Reactive[PlaceholderVariant] = reactive("default")
variant: Reactive[PlaceholderVariant] = reactive[PlaceholderVariant]("default")
_renderables: dict[PlaceholderVariant, str]
@@ -150,4 +150,4 @@ class Placeholder(Widget):
"""Update the placeholder "size" variant with the new placeholder size."""
self._renderables["size"] = self._SIZE_RENDER_TEMPLATE.format(*event.size)
if self.variant == "size":
self.refresh(layout=False)
self.refresh()