mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into toggle-boxen
This commit is contained in:
@@ -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
14
FAQ.md
@@ -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?
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
16
questions/copy-text.question.md
Normal file
16
questions/copy-text.question.md
Normal 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.
|
||||
@@ -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__
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user