mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' of github.com:Textualize/textual into allow-print-headless
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
2
poetry.lock
generated
@@ -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 = [
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
28
tests/input/test_input_terminal_cursor.py
Normal file
28
tests/input/test_input_terminal_cursor.py
Normal 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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user