Merge branch 'main' into fix-1607

This commit is contained in:
Rodrigo Girão Serrão
2023-01-20 11:33:47 +00:00
17 changed files with 365 additions and 43 deletions

View File

@@ -1,5 +1,7 @@
<!-- Auto-generated by FAQtory -->
<!-- Do not edit by hand! -->
# Frequently Asked Questions
# Frequently Asked Questions
{%- for question in questions %}
- [{{ question.title }}](#{{ question.slug }})

View File

@@ -9,10 +9,12 @@ assignees: ''
Have you checked closed issues? https://github.com/Textualize/textual/issues?q=is%3Aissue+is%3Aclosed
Please give a brief but clear explanation of the issue.
Please give a brief but clear explanation of the issue. If you can, include a complete working example that demonstrates the bug. **Check it can run without modifications.**
What Operating System are you running on?
It will be helpful if you run the following command and paste the results:
Feel free to add screenshots and/or videos. These can be very helpful!
```
textual diagnose
```
If you can, include a complete working example that demonstrates the bug. Check it can run without modifications.
Feel free to add screenshots and / or videos. These can be very helpful!

View File

@@ -5,7 +5,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## [0.10.0] - Unreleased
## [0.10.1] - 2023-01-20
### Added
- Added Strip.text property https://github.com/Textualize/textual/issues/1620
### Fixed
- Fixed `textual diagnose` crash on older supported Python versions. https://github.com/Textualize/textual/issues/1622
### Changed
- The default filename for screenshots uses a datetime format similar to ISO8601, but with reserved characters replaced by underscores https://github.com/Textualize/textual/pull/1518
## [0.10.0] - 2023-01-19
### Added
@@ -41,9 +56,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Behavior of widget `Input` when rendering after programmatic value change and related scenarios https://github.com/Textualize/textual/issues/1477 https://github.com/Textualize/textual/issues/1443
- `DataTable.show_cursor` now correctly allows cursor toggling https://github.com/Textualize/textual/pull/1547
- Fixed cursor not being visible on `DataTable` mount when `fixed_columns` were used https://github.com/Textualize/textual/pull/1547
- Fixed `DataTable` cursors not resetting to origin on `clear()` https://github.com/Textualize/textual/pull/1601
- Fixed TextLog wrapping issue https://github.com/Textualize/textual/issues/1554
- Fixed issue with TextLog not writing anything before layout https://github.com/Textualize/textual/issues/1498
- Fixed an exception when populating a child class of `ListView` purely from `compose` https://github.com/Textualize/textual/issues/1588
- Fixed freeze in tests https://github.com/Textualize/textual/issues/1608
## [0.9.1] - 2022-12-30
@@ -362,6 +379,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.10.0]: https://github.com/Textualize/textual/compare/v0.9.1...v0.10.0
[0.9.1]: https://github.com/Textualize/textual/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/Textualize/textual/compare/v0.8.2...v0.9.0
[0.8.2]: https://github.com/Textualize/textual/compare/v0.8.1...v0.8.2

19
FAQ.md
View File

@@ -1,9 +1,12 @@
<!-- Auto-generated by FAQtory -->
<!-- Do not edit by hand! -->
# 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 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)
<a name="does-textual-support-images"></a>
## Does Textual support images?
@@ -87,6 +90,20 @@ Greetings(to_greet="davep").run()
Greetings("Well hello", "there").run()
```
<a name="why-doesn't-textual-support-ansi-themes"></a>
## Why doesn't Textual support ANSI themes?
Textual will not generate escape sequences for the 16 themeable *ANSI* colors.
This is an intentional design decision we took for for the following reasons:
- Not everyone has a carefully chosen ANSI color theme. Color combinations which may look fine on your system, may be unreadable on another machine. There is very little an app author or Textual can do to resolve this. Asking users to simply pick a better theme is not a good solution, since not all users will know how.
- ANSI colors can't be manipulated in the way Textual can do with other colors. Textual can blend colors and produce light and dark shades from an original color, which is used to create more readable text and user interfaces. Color blending will also be used to power future accessibility features.
Textual has a design system which guarantees apps will be readable on all platforms and terminals, and produces better results than ANSI colors.
There is currently a light and dark version of the design system, but more are planned. It will also be possible for users to customize the source colors on a per-app or per-system basis. This means that in the future you will be able to modify the core colors to blend in with your chosen terminal theme.
<hr>
Generated by [FAQtory](https://github.com/willmcgugan/faqtory)
Generated by [FAQtory](https://github.com/willmcgugan/faqtory)

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "0.9.1"
version = "0.10.1"
homepage = "https://github.com/Textualize/textual"
description = "Modern Text User Interface framework"
authors = ["Will McGugan <will@textualize.io>"]

View File

@@ -0,0 +1,17 @@
---
title: "Why doesn't Textual support ANSI themes?"
alt_titles:
- "Textual should use system terminal colors for cyan, etc"
- "ANSI theme colors not working"
---
Textual will not generate escape sequences for the 16 themeable *ANSI* colors.
This is an intentional design decision we took for for the following reasons:
- Not everyone has a carefully chosen ANSI color theme. Color combinations which may look fine on your system, may be unreadable on another machine. There is very little an app author or Textual can do to resolve this. Asking users to simply pick a better theme is not a good solution, since not all users will know how.
- ANSI colors can't be manipulated in the way Textual can do with other colors. Textual can blend colors and produce light and dark shades from an original color, which is used to create more readable text and user interfaces. Color blending will also be used to power future accessibility features.
Textual has a design system which guarantees apps will be readable on all platforms and terminals, and produces better results than ANSI colors.
There is currently a light and dark version of the design system, but more are planned. It will also be possible for users to customize the source colors on a per-app or per-system basis. This means that in the future you will be able to modify the core colors to blend in with your chosen terminal theme.

23
src/textual/_asyncio.py Normal file
View File

@@ -0,0 +1,23 @@
"""
Compatibility layer for asyncio.
"""
from __future__ import annotations
import sys
__all__ = ["create_task"]
if sys.version_info >= (3, 8):
from asyncio import create_task
else:
import asyncio
from asyncio import create_task as _create_task
from typing import Awaitable
def create_task(coroutine: Awaitable, *, name: str | None = None) -> asyncio.Task:
"""Schedule the execution of a coroutine object in a spawn task."""
return _create_task(coroutine)

View File

@@ -1,8 +1,6 @@
from __future__ import annotations
import asyncio
from concurrent.futures import Future
from functools import partial
import inspect
import io
import os
@@ -12,8 +10,10 @@ import threading
import unicodedata
import warnings
from asyncio import Task
from concurrent.futures import Future
from contextlib import asynccontextmanager, redirect_stderr, redirect_stdout
from datetime import datetime
from functools import partial
from pathlib import Path, PurePath
from queue import Queue
from time import perf_counter
@@ -41,9 +41,10 @@ from rich.protocol import is_renderable
from rich.segment import Segment, Segments
from rich.traceback import Traceback
from . import actions, Logger, LogGroup, LogVerbosity, events, log, messages
from . import Logger, LogGroup, LogVerbosity, actions, events, log, messages
from ._animator import DEFAULT_EASING, Animatable, Animator, EasingFunction
from ._ansi_sequences import SYNC_END, SYNC_START
from ._asyncio import create_task
from ._callback import invoke
from ._context import active_app
from ._event_broker import NoHandler, extract_handler_actions
@@ -69,7 +70,6 @@ from .renderables.blank import Blank
from .screen import Screen
from .widget import AwaitMount, Widget
if TYPE_CHECKING:
from .devtools.client import DevtoolsClient
from .pilot import Pilot
@@ -722,7 +722,7 @@ class App(Generic[ReturnType], DOMNode):
self,
filename: str | None = None,
path: str = "./",
time_format: str = "%Y%m%d %H%M%S %f",
time_format: str | None = None,
) -> str:
"""Save an SVG screenshot of the current screen.
@@ -730,17 +730,21 @@ class App(Generic[ReturnType], DOMNode):
filename: Filename of SVG screenshot, or None to auto-generate
a filename with the date and time. Defaults to None.
path: Path to directory for output. Defaults to current working directory.
time_format: Time format to use if filename is None. Defaults to "%Y-%m-%d %X %f".
time_format: Date and time format to use if filename is None.
Defaults to a format like ISO 8601 with some reserved characters replaced with underscores.
Returns:
Filename of screenshot.
"""
if filename is None:
svg_filename = (
f"{self.title.lower()} {datetime.now().strftime(time_format)}.svg"
)
for reserved in '<>:"/\\|?*':
svg_filename = svg_filename.replace(reserved, "_")
if time_format is None:
dt = datetime.now().isoformat()
else:
dt = datetime.now().strftime(time_format)
svg_filename_stem = f"{self.title.lower()} {dt}"
for reserved in ' <>:"/\\|?*.':
svg_filename_stem = svg_filename_stem.replace(reserved, "_")
svg_filename = svg_filename_stem + ".svg"
else:
svg_filename = filename
svg_path = os.path.expanduser(os.path.join(path, svg_filename))
@@ -859,7 +863,7 @@ class App(Generic[ReturnType], DOMNode):
)
# Launch the app in the "background"
app_task = asyncio.create_task(run_app(app))
app_task = create_task(run_app(app), name=f"run_test {app}")
# Wait until the app has performed all startup routines.
await app_ready_event.wait()
@@ -914,7 +918,9 @@ class App(Generic[ReturnType], DOMNode):
raise
pilot = Pilot(app)
auto_pilot_task = asyncio.create_task(run_auto_pilot(auto_pilot, pilot))
auto_pilot_task = create_task(
run_auto_pilot(auto_pilot, pilot), name=repr(pilot)
)
try:
await app._process_messages(
@@ -1653,6 +1659,7 @@ class App(Generic[ReturnType], DOMNode):
return []
new_widgets = list(widgets)
if before is not None or after is not None:
# There's a before or after, which means there's going to be an
# insertion, so make it easier to get the new things in the
@@ -1668,6 +1675,11 @@ class App(Generic[ReturnType], DOMNode):
if widget.children:
self._register(widget, *widget.children)
apply_stylesheet(widget)
if not self._running:
# If the app is not running, prevent awaiting of the widget tasks
return []
return list(widgets)
def _unregister(self, widget: Widget) -> None:
@@ -2111,7 +2123,9 @@ class App(Generic[ReturnType], DOMNode):
removed_widgets = self._detach_from_dom(widgets)
finished_event = asyncio.Event()
asyncio.create_task(prune_widgets_task(removed_widgets, finished_event))
create_task(
prune_widgets_task(removed_widgets, finished_event), name="prune nodes"
)
return AwaitRemove(finished_event)

View File

@@ -1,5 +1,6 @@
"""Textual CLI command code to print diagnostic information."""
from __future__ import annotations
import os
import sys
import platform

View File

@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable
from weakref import WeakSet
from . import Logger, events, log, messages
from ._asyncio import create_task
from ._callback import invoke
from ._context import NoActiveAppError, active_app
from ._time import time
@@ -304,7 +305,12 @@ class MessagePump(metaclass=MessagePumpMeta):
def _start_messages(self) -> None:
"""Start messages task."""
if self.app._running:
self._task = asyncio.create_task(self._process_messages())
self._task = create_task(
self._process_messages(), name=f"message pump {self}"
)
else:
self._closing = True
self._closed = True
async def _process_messages(self) -> None:
self._running = True

View File

@@ -43,6 +43,11 @@ class Strip:
yield self._segments
yield self.cell_length
@property
def text(self) -> str:
"""Segment text."""
return "".join(segment.text for segment in self._segments)
@classmethod
def blank(cls, cell_length: int, style: Style | None) -> Strip:
"""Create a blank strip.

View File

@@ -7,7 +7,6 @@ Timer objects are created by [set_interval][textual.message_pump.MessagePump.set
from __future__ import annotations
import asyncio
import weakref
from asyncio import CancelledError, Event, Task
from typing import Awaitable, Callable, Union
@@ -15,6 +14,7 @@ from typing import Awaitable, Callable, Union
from rich.repr import Result, rich_repr
from . import _clock, events
from ._asyncio import create_task
from ._callback import invoke
from ._context import active_app
from ._time import sleep
@@ -89,7 +89,7 @@ class Timer:
Returns:
A Task instance for the timer.
"""
self._task = asyncio.create_task(self._run_timer())
self._task = create_task(self._run_timer(), name=self.name)
return self._task
def stop_no_wait(self) -> None:

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from asyncio import Event as AsyncEvent
from asyncio import Lock, wait
from asyncio import Lock, create_task, wait
from collections import Counter
from fractions import Fraction
@@ -36,6 +36,7 @@ from rich.text import Text
from rich.traceback import Traceback
from . import errors, events, messages
from ._asyncio import create_task
from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
from ._arrange import DockArrangeResult, arrange
from ._context import active_app
@@ -94,7 +95,7 @@ class AwaitMount:
async def await_mount() -> None:
if self._widgets:
aws = [
create_task(widget._mounted_event.wait())
create_task(widget._mounted_event.wait(), name="await mount")
for widget in self._widgets
]
if aws:

View File

@@ -14,23 +14,27 @@ from rich.text import Text, TextType
from .. import events, messages
from .._cache import LRUCache
from ..coordinate import Coordinate
from .._segment_tools import line_crop
from .._types import SegmentLines
from .._typing import Literal
from ..binding import Binding
from ..coordinate import Coordinate
from ..geometry import Region, Size, Spacing, clamp
from ..message import Message
from ..reactive import Reactive
from ..render import measure
from ..scroll_view import ScrollView
from ..strip import Strip
from .._typing import Literal
CursorType = Literal["cell", "row", "column", "none"]
CELL: CursorType = "cell"
CellType = TypeVar("CellType")
class CellDoesNotExist(Exception):
pass
def default_cell_formatter(obj: object) -> RenderableType | None:
"""Format a cell in to a renderable.
@@ -229,9 +233,16 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
Returns:
The value of the cell.
Raises:
CellDoesNotExist: If there is no cell with the given coordinate.
"""
row, column = coordinate
return self.data[row][column]
try:
cell_value = self.data[row][column]
except KeyError:
raise CellDoesNotExist(f"No cell exists at {coordinate!r}") from None
return cell_value
def _clear_caches(self) -> None:
self._row_render_cache.clear()
@@ -293,18 +304,26 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
def _highlight_cell(self, coordinate: Coordinate) -> None:
"""Apply highlighting to the cell at the coordinate, and emit event."""
self.refresh_cell(*coordinate)
cell_value = self.get_cell_value(coordinate)
self.emit_no_wait(DataTable.CellHighlighted(self, cell_value, coordinate))
try:
cell_value = self.get_cell_value(coordinate)
except CellDoesNotExist:
# The cell may not exist e.g. when the table is cleared.
# In that case, there's nothing for us to do here.
return
else:
self.emit_no_wait(DataTable.CellHighlighted(self, cell_value, coordinate))
def _highlight_row(self, row_index: int) -> None:
"""Apply highlighting to the row at the given index, and emit event."""
self.refresh_row(row_index)
self.emit_no_wait(DataTable.RowHighlighted(self, row_index))
if row_index in self.data:
self.emit_no_wait(DataTable.RowHighlighted(self, row_index))
def _highlight_column(self, column_index: int) -> None:
"""Apply highlighting to the column at the given index, and emit event."""
self.refresh_column(column_index)
self.emit_no_wait(DataTable.ColumnHighlighted(self, column_index))
if column_index < len(self.columns):
self.emit_no_wait(DataTable.ColumnHighlighted(self, column_index))
def validate_cursor_cell(self, value: Coordinate) -> Coordinate:
return self._clamp_cursor_cell(value)
@@ -317,18 +336,12 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
def watch_cursor_type(self, old: str, new: str) -> None:
self._set_hover_cursor(False)
row_index, column_index = self.cursor_cell
# Apply the highlighting to the newly relevant cells
if new == "cell":
self._highlight_cell(self.cursor_cell)
elif new == "row":
self._highlight_row(row_index)
elif new == "column":
self._highlight_column(column_index)
if self.show_cursor:
self._highlight_cursor()
# Refresh cells that were previously impacted by the cursor
# but may no longer be.
row_index, column_index = self.cursor_cell
if old == "cell":
self.refresh_cell(row_index, column_index)
elif old == "row":
@@ -338,6 +351,17 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self._scroll_cursor_into_view()
def _highlight_cursor(self) -> None:
row_index, column_index = self.cursor_cell
cursor_type = self.cursor_type
# Apply the highlighting to the newly relevant cells
if cursor_type == "cell":
self._highlight_cell(self.cursor_cell)
elif cursor_type == "row":
self._highlight_row(row_index)
elif cursor_type == "column":
self._highlight_column(column_index)
def _update_dimensions(self, new_rows: Iterable[int]) -> None:
"""Called to recalculate the virtual (scrollable) size."""
for row_index in new_rows:
@@ -410,6 +434,8 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self.columns.clear()
self._line_no = 0
self._require_update_dimensions = True
self.cursor_cell = Coordinate(0, 0)
self.hover_cell = Coordinate(0, 0)
self.refresh()
def add_columns(self, *labels: TextType) -> None:
@@ -471,6 +497,15 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
self.cursor_cell = self.cursor_cell
self.check_idle()
# If a position has opened for the cursor to appear, where it previously
# could not (e.g. when there's no data in the table), then a highlighted
# event is emitted, since there's now a highlighted cell when there wasn't
# before.
cell_now_available = self.row_count == 1 and len(self.columns) > 0
visible_cursor = self.show_cursor and self.cursor_type != "none"
if cell_now_available and visible_cursor:
self._highlight_cursor()
def add_rows(self, rows: Iterable[Iterable[CellType]]) -> None:
"""Add a number of rows.

149
tests/test_data_table.py Normal file
View File

@@ -0,0 +1,149 @@
from textual.app import App
from textual.coordinate import Coordinate
from textual.message import Message
from textual.widgets import DataTable
class DataTableApp(App):
messages = []
messages_to_record = {
"CellHighlighted",
"CellSelected",
"RowHighlighted",
"RowSelected",
"ColumnHighlighted",
"ColumnSelected",
}
def compose(self):
table = DataTable()
table.focus()
yield table
def record_data_table_event(self, message: Message) -> None:
name = message.__class__.__name__
if name in self.messages_to_record:
self.messages.append(name)
async def _on_message(self, message: Message) -> None:
await super()._on_message(message)
self.record_data_table_event(message)
async def test_datatable_message_emission():
app = DataTableApp()
messages = app.messages
expected_messages = []
async with app.run_test() as pilot:
table = app.query_one(DataTable)
assert messages == expected_messages
table.add_columns("Column0", "Column1")
table.add_rows([["0/0", "0/1"], ["1/0", "1/1"], ["2/0", "2/1"]])
# A CellHighlighted is emitted because there were no rows (and
# therefore no highlighted cells), but then a row was added, and
# so the cell at (0, 0) became highlighted.
expected_messages.append("CellHighlighted")
await pilot.pause(2 / 100)
assert messages == expected_messages
# Pressing Enter when the cursor is on a cell emits a CellSelected
await pilot.press("enter")
expected_messages.append("CellSelected")
await pilot.pause(2 / 100)
assert messages == expected_messages
# Moving the cursor left and up when the cursor is at origin
# emits no events, since the cursor doesn't move at all.
await pilot.press("left", "up")
await pilot.pause(2 / 100)
assert messages == expected_messages
# ROW CURSOR
# Switch over to the row cursor... should emit a `RowHighlighted`
table.cursor_type = "row"
expected_messages.append("RowHighlighted")
await pilot.pause(2 / 100)
assert messages == expected_messages
# Select the row...
await pilot.press("enter")
expected_messages.append("RowSelected")
await pilot.pause(2 / 100)
assert messages == expected_messages
# COLUMN CURSOR
# Switching to the column cursor emits a `ColumnHighlighted`
table.cursor_type = "column"
expected_messages.append("ColumnHighlighted")
await pilot.pause(2 / 100)
assert messages == expected_messages
# Select the column...
await pilot.press("enter")
expected_messages.append("ColumnSelected")
await pilot.pause(2 / 100)
assert messages == expected_messages
# NONE CURSOR
# No messages get emitted at all...
table.cursor_type = "none"
await pilot.press("up", "down", "left", "right", "enter")
await pilot.pause(2 / 100)
# No new messages since cursor not visible
assert messages == expected_messages
# Edge case - if show_cursor is False, and the cursor type
# is changed back to a visible type, then no messages should
# be emitted since the cursor is still not visible.
table.show_cursor = False
table.cursor_type = "cell"
await pilot.press("up", "down", "left", "right", "enter")
await pilot.pause(2 / 100)
# No new messages since show_cursor = False
assert messages == expected_messages
# Now when show_cursor is set back to True, the appropriate
# message should be emitted for highlighting the cell.
table.show_cursor = True
expected_messages.append("CellHighlighted")
await pilot.pause(2 / 100)
assert messages == expected_messages
# Likewise, if the cursor_type is "none", and we change the
# show_cursor to True, then no events should be raised since
# the cursor is still not visible to the user.
table.cursor_type = "none"
await pilot.press("up", "down", "left", "right", "enter")
await pilot.pause(2 / 100)
assert messages == expected_messages
async def test_clear():
app = DataTableApp()
async with app.run_test():
table = app.query_one(DataTable)
assert table.cursor_cell == Coordinate(0, 0)
assert table.hover_cell == Coordinate(0, 0)
# Add some data and update cursor positions
table.add_column("Column0")
table.add_rows([["Row0"], ["Row1"], ["Row2"]])
table.cursor_cell = Coordinate(1, 0)
table.hover_cell = Coordinate(2, 0)
# Ensure the cursor positions are reset to origin on clear()
table.clear()
assert table.cursor_cell == Coordinate(0, 0)
assert table.hover_cell == Coordinate(0, 0)
# Ensure that the table has been cleared
assert table.data == {}
assert table.rows == {}
assert len(table.columns) == 1
# Clearing the columns too
table.clear(columns=True)
assert len(table.columns) == 0

26
tests/test_freeze.py Normal file
View File

@@ -0,0 +1,26 @@
import pytest
from textual.app import App
from textual.screen import Screen
from textual.widgets import Footer, Header, Input
class MyScreen(Screen):
def compose(self):
yield Header()
yield Input()
yield Footer()
class MyApp(App):
def on_mount(self):
self.install_screen(MyScreen(), "myscreen")
self.push_screen("myscreen")
async def test_freeze():
"""Regression test for https://github.com/Textualize/textual/issues/1608"""
app = MyApp()
with pytest.raises(Exception):
async with app.run_test():
raise Exception("never raised")

View File

@@ -173,3 +173,9 @@ def test_index_cell_position_index_too_large():
strip = Strip([Segment("abcdef"), Segment("ghi")])
with pytest.raises(NoCellPositionForIndex):
strip.index_to_cell_position(100)
def test_text():
assert Strip([]).text == ""
assert Strip([Segment("foo")]).text == "foo"
assert Strip([Segment("foo"), Segment("bar")]).text == "foobar"