Merge branch 'main' of github.com:Textualize/textual into allow-print-headless

This commit is contained in:
Darren Burns
2023-10-09 10:27:20 +01:00
13 changed files with 211 additions and 87 deletions

View File

@@ -11,12 +11,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `Pilot.click`/`Pilot.hover` can't use `Screen` as a selector https://github.com/Textualize/textual/issues/3395
- App exception when a `Tree` is initialized/mounted with `disabled=True` https://github.com/Textualize/textual/issues/3407
- Fixed `print` locations not being correctly reported in `textual console` https://github.com/Textualize/textual/issues/3237
- Fix location of IME and emoji popups https://github.com/Textualize/textual/pull/3408
- Fixed application freeze when pasting an emoji into an application on Windows https://github.com/Textualize/textual/issues/3178
- Fixed duplicate option ID handling in the `OptionList` https://github.com/Textualize/textual/issues/3455
### Added
- `OutOfBounds` exception to be raised by `Pilot` https://github.com/Textualize/textual/pull/3360
- `TextArea.cursor_screen_offset` property for getting the screen-relative position of the cursor https://github.com/Textualize/textual/pull/3408
- `Input.cursor_screen_offset` property for getting the screen-relative position of the cursor https://github.com/Textualize/textual/pull/3408
- Reactive `cell_padding` (and respective parameter) to define horizontal cell padding in data table columns https://github.com/Textualize/textual/issues/3435
- Added `Input.clear` method https://github.com/Textualize/textual/pull/3430
- Added `TextArea.SelectionChanged` and `TextArea.Changed` messages https://github.com/Textualize/textual/pull/3442

View File

@@ -22,7 +22,7 @@ Textual applications.
<!-- more -->
With this in mind we've created
[`textual-plotext`](https://pypi.org/project/textual-plotext/): a library
[`textual-plotext`](https://github.com/Textualize/textual-plotext): a library
that provides a widget for using Plotext plots in your app. In doing this
we've tried our best to make it as similar as possible to using Plotext in a
conventional Python script.

View File

@@ -10,7 +10,7 @@ A table widget optimized for displaying a lot of data.
### Adding data
The following example shows how to fill a table with data.
First, we use [add_columns][textual.widgets.DataTable.add_rows] to include the `lane`, `swimmer`, `country`, and `time` columns in the table.
First, we use [add_columns][textual.widgets.DataTable.add_columns] to include the `lane`, `swimmer`, `country`, and `time` columns in the table.
After that, we use the [add_rows][textual.widgets.DataTable.add_rows] method to insert the rows into the table.
=== "Output"

2
poetry.lock generated
View File

@@ -1194,7 +1194,7 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
content-hash = "21a330a4dcfbcd589dd067ec328c67e3db9a787eda0261a0dd07438a1438f319"
content-hash = "b427229e7fee60f06b1853a3e66669d2f185dd463e5632422e57ed1d254ace14"
[metadata.files]
aiohttp = [

View File

@@ -66,7 +66,7 @@ time-machine = "^2.6.0"
mkdocs-rss-plugin = "^1.5.0"
httpx = "^0.23.1"
types-setuptools = "^67.2.0.1"
textual-dev = "^1.1.0"
textual-dev = "^1.2.0"
pytest-asyncio = "*"
pytest-textual-snapshot = "0.4.0" # pinned until https://github.com/Textualize/pytest-textual-snapshot/pull/7 released
types-tree-sitter = "^0.20.1.4"

View File

@@ -53,6 +53,7 @@ import rich
import rich.repr
from rich import terminal_theme
from rich.console import Console, RenderableType
from rich.control import Control
from rich.protocol import is_renderable
from rich.segment import Segment, Segments
from rich.traceback import Traceback
@@ -418,6 +419,12 @@ class App(Generic[ReturnType], DOMNode):
self._animate = self._animator.bind(self)
self.mouse_position = Offset(0, 0)
self.cursor_position = Offset(0, 0)
"""The position of the terminal cursor in screen-space.
This can be set by widgets and is useful for controlling the
positioning of OS IME and emoji popup menus."""
self._exception: Exception | None = None
"""The unhandled exception which is leading to the app shutting down,
or None if the app is still running with no unhandled exceptions."""
@@ -1156,7 +1163,10 @@ class App(Generic[ReturnType], DOMNode):
stderr: True if the print was to stderr, or False for stdout.
"""
if self._devtools_redirector is not None:
self._devtools_redirector.write(text)
current_frame = inspect.currentframe()
self._devtools_redirector.write(
text, current_frame.f_back if current_frame is not None else None
)
for target, (_stdout, _stderr) in self._capture_print.items():
if (_stderr and stderr) or (_stdout and not stderr):
target.post_message(events.Print(text, stderr=stderr))
@@ -2447,7 +2457,11 @@ class App(Generic[ReturnType], DOMNode):
try:
try:
if isinstance(renderable, CompositorUpdate):
cursor_x, cursor_y = self.cursor_position
terminal_sequence = renderable.render_segments(console)
terminal_sequence += Control.move_to(
cursor_x, cursor_y
).segment.text
else:
segments = console.render(renderable)
terminal_sequence = console._render_buffer(segments)
@@ -2457,7 +2471,9 @@ class App(Generic[ReturnType], DOMNode):
self._driver.write(terminal_sequence)
finally:
self._end_update()
self._driver.flush()
finally:
self.post_display_hook()

View File

@@ -515,7 +515,7 @@ class Screen(Generic[ScreenResultType], Widget):
chosen = candidate
break
# Go with the what was found.
# Go with what was found.
self.set_focus(chosen)
def _update_focus_styles(

View File

@@ -15,7 +15,7 @@ from .. import events
from .._segment_tools import line_crop
from ..binding import Binding, BindingType
from ..events import Blur, Focus, Mount
from ..geometry import Size
from ..geometry import Offset, Size
from ..message import Message
from ..reactive import reactive
from ..suggester import Suggester, SuggestionReady
@@ -254,6 +254,7 @@ class Input(Widget, can_focus=True):
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
if value is not None:
self.value = value
self.placeholder = placeholder
self.highlighter = highlighter
self.password = password
@@ -327,6 +328,14 @@ class Input(Widget, can_focus=True):
else:
self.view_position = self.view_position
self.app.cursor_position = self.cursor_screen_offset
@property
def cursor_screen_offset(self) -> Offset:
"""The offset of the cursor of this input in screen-space. (x, y)/(column, row)"""
x, y, _width, _height = self.content_region
return Offset(x + self._cursor_offset - self.view_position, y)
async def _watch_value(self, value: str) -> None:
self._suggestion = ""
if self.suggester and value:
@@ -425,6 +434,7 @@ class Input(Widget, can_focus=True):
self.cursor_position = len(self.value)
if self.cursor_blink:
self.blink_timer.resume()
self.app.cursor_position = self.cursor_screen_offset
async def _on_key(self, event: events.Key) -> None:
self._cursor_visible = True

View File

@@ -410,6 +410,7 @@ TextArea {
if match_row in range(*self._visible_line_indices):
self.refresh_lines(match_row)
self.app.cursor_position = self.cursor_screen_offset
self.post_message(self.SelectionChanged(selection, self))
def find_matching_bracket(
@@ -660,7 +661,14 @@ TextArea {
Returns:
A tuple (top, bottom) indicating the top and bottom visible line indices.
"""
return self.scroll_offset.y, self.scroll_offset.y + self.size.height
_, scroll_offset_y = self.scroll_offset
return scroll_offset_y, scroll_offset_y + self.size.height
def _watch_scroll_x(self) -> None:
self.app.cursor_position = self.cursor_screen_offset
def _watch_scroll_y(self) -> None:
self.app.cursor_position = self.cursor_screen_offset
def load_text(self, text: str) -> None:
"""Load text into the TextArea.
@@ -1043,6 +1051,7 @@ TextArea {
def _on_focus(self, _: events.Focus) -> None:
self._restart_blink()
self.app.cursor_position = self.cursor_screen_offset
def _toggle_cursor_blink_visible(self) -> None:
"""Toggle visibility of the cursor for the purposes of 'cursor blink'."""
@@ -1257,6 +1266,23 @@ TextArea {
"""
self.move_cursor(location, select=not self.selection.is_empty)
@property
def cursor_screen_offset(self) -> Offset:
"""The offset of the cursor relative to the screen."""
cursor_row, cursor_column = self.cursor_location
scroll_x, scroll_y = self.scroll_offset
region_x, region_y, _width, _height = self.content_region
offset_x = (
region_x
+ self.get_column_width(cursor_row, cursor_column)
- scroll_x
+ self.gutter_width
)
offset_y = region_y + cursor_row - scroll_y
return Offset(offset_x, offset_y)
@property
def cursor_at_first_line(self) -> bool:
"""True if and only if the cursor is on the first line."""

View File

@@ -0,0 +1,28 @@
from textual.app import App, ComposeResult
from textual.geometry import Offset
from textual.widgets import Input
class InputApp(App):
# Apply padding to ensure gutter accounted for.
CSS = "Input { padding: 4 8 }"
def compose(self) -> ComposeResult:
yield Input("こんにちは!")
async def test_initial_terminal_cursor_position():
app = InputApp()
async with app.run_test():
# The input is focused so the terminal cursor position should update.
assert app.cursor_position == Offset(21, 5)
async def test_terminal_cursor_position_update_on_cursor_move():
app = InputApp()
async with app.run_test():
input_widget = app.query_one(Input)
input_widget.action_cursor_left()
input_widget.action_cursor_left()
# We went left over two double-width characters
assert app.cursor_position == Offset(17, 5)

File diff suppressed because one or more lines are too long

View File

@@ -18,10 +18,12 @@ class TextAreaApp(App):
yield text_area
def test_default_selection():
async def test_default_selection():
"""The cursor starts at (0, 0) in the document."""
text_area = TextArea()
assert text_area.selection == Selection.cursor((0, 0))
app = TextAreaApp()
async with app.run_test():
text_area = app.query_one(TextArea)
assert text_area.selection == Selection.cursor((0, 0))
async def test_cursor_location_get():
@@ -294,3 +296,41 @@ async def test_select_line(index, content, expected_selection):
text_area.select_line(index)
assert text_area.selection == expected_selection
async def test_cursor_screen_offset_and_terminal_cursor_position_update():
class TextAreaCursorScreenOffset(App):
def compose(self) -> ComposeResult:
yield TextArea("abc\ndef")
app = TextAreaCursorScreenOffset()
async with app.run_test():
text_area = app.query_one(TextArea)
assert app.cursor_position == (3, 0)
text_area.cursor_location = (1, 1)
assert text_area.cursor_screen_offset == (4, 1)
# Also ensure that this update has been reported back to the app
# for the benefit of IME/emoji popups.
assert app.cursor_position == (4, 1)
async def test_cursor_screen_offset_and_terminal_cursor_position_scrolling():
class TextAreaCursorScreenOffset(App):
def compose(self) -> ComposeResult:
yield TextArea("AB\nAB\nAB\nAB\nAB\nAB\n")
app = TextAreaCursorScreenOffset()
async with app.run_test(size=(80, 2)) as pilot:
text_area = app.query_one(TextArea)
assert app.cursor_position == (3, 0)
text_area.cursor_location = (5, 0)
await pilot.pause()
assert text_area.cursor_screen_offset == (3, 1)
assert app.cursor_position == (3, 1)