mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into fix-1607
This commit is contained in:
@@ -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 }})
|
||||
|
||||
10
.github/ISSUE_TEMPLATE/bug_report.md
vendored
10
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -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!
|
||||
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
19
FAQ.md
@@ -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)
|
||||
|
||||
@@ -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>"]
|
||||
|
||||
17
questions/why-no-ansi-themes.question.md
Normal file
17
questions/why-no-ansi-themes.question.md
Normal 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
23
src/textual/_asyncio.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Textual CLI command code to print diagnostic information."""
|
||||
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
149
tests/test_data_table.py
Normal 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
26
tests/test_freeze.py
Normal 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")
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user