From e6ad1bd991857e9ce74db62c7bb916c71f760433 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Tue, 13 Feb 2024 15:16:57 +0000 Subject: [PATCH] Text area read only (#4151) * Add read_only reactive * Using nested CSS in TextArea and adding COMPONENT_CLASS for read-only cursor * Applying/removing CSS class `.-read-only` in TextArea * Preventing some edits in read-only mode. * Clearer distinction between user/keyboard driven edits and programmatic edits * Ensure we refresh cursor correctly when pressing key in read-only mode * Add test of paste in read-only mode * Fix typo in docstring * Ensure "delete line" keybinding doesnt move cursor in read_only mode in TextArea * Add clarification to docs based on issue #4145 * Add test to ensure read-only cursor colour * Update CHANGELOG * Fix cursor styling in CSS on read-only * Fix a docstring * Improving docstrings * Improving docstrings * Simplify fixtures * Test to ensure API driven editing still works on TextArea.read_only=True --- CHANGELOG.md | 1 + src/textual/_text_area_theme.py | 2 +- src/textual/document/_document_navigator.py | 6 +- src/textual/widgets/_text_area.py | 186 +++++++++++++----- .../__snapshots__/test_snapshots.ambr | 83 ++++++++ tests/snapshot_tests/test_snapshots.py | 14 ++ tests/text_area/test_edit_via_api.py | 22 ++- tests/text_area/test_edit_via_bindings.py | 43 ++++ tests/text_area/test_selection_bindings.py | 87 ++++---- 9 files changed, 338 insertions(+), 106 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2241508c9c..220e7e0f2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added +- TextArea now has `read_only` mode https://github.com/Textualize/textual/pull/4151 - Add some syntax highlighting to TextArea default theme https://github.com/Textualize/textual/pull/4149 ## [0.51.1] - 2024-02-09 diff --git a/src/textual/_text_area_theme.py b/src/textual/_text_area_theme.py index b9f920600b..5fbcee9967 100644 --- a/src/textual/_text_area_theme.py +++ b/src/textual/_text_area_theme.py @@ -107,7 +107,7 @@ def apply_css(self, text_area: TextArea) -> None: self.cursor_style = cursor_style else: # There's no component style either, fallback to a default. - self.cursor_style = Style( + self.cursor_style = Style.from_color( color=background_color.rich_color, bgcolor=background_color.inverse.rich_color, ) diff --git a/src/textual/document/_document_navigator.py b/src/textual/document/_document_navigator.py index e265f03b2a..25b44e8422 100644 --- a/src/textual/document/_document_navigator.py +++ b/src/textual/document/_document_navigator.py @@ -101,11 +101,15 @@ def is_start_of_wrapped_line(self, location: Location) -> bool: def is_end_of_document_line(self, location: Location) -> bool: """True if the location is at the end of a line in the document. + Note that the "end" of a line is equal to its length (one greater + than the final index), since there is a space at the end of the line + for the cursor to rest. + Args: location: The location to examine. Returns: - True if and only if the document is on the last line of the document. + True if and only if the document is at the end of a line in the document. """ row, column = location row_length = len(self._document[row]) diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py index d5f4d8efef..085ce16a3f 100644 --- a/src/textual/widgets/_text_area.py +++ b/src/textual/widgets/_text_area.py @@ -91,41 +91,51 @@ class TextArea(ScrollView, can_focus=True): border: tall $background; padding: 0 1; + & .text-area--gutter { + color: $text 40%; + } + + & .text-area--cursor-gutter { + color: $text 60%; + background: $boost; + text-style: bold; + } + + & .text-area--cursor-line { + background: $boost; + } + + & .text-area--selection { + background: $accent-lighten-1 40%; + } + + & .text-area--matching-bracket { + background: $foreground 30%; + } + &:focus { border: tall $accent; } -} - -.text-area--cursor { - color: $text 90%; - background: $foreground 90%; -} - -TextArea:light .text-area--cursor { - color: $text 90%; - background: $foreground 70%; -} - -.text-area--gutter { - color: $text 40%; -} - -.text-area--cursor-line { - background: $boost; -} - -.text-area--cursor-gutter { - color: $text 60%; - background: $boost; - text-style: bold; -} - -.text-area--selection { - background: $accent-lighten-1 40%; -} - -.text-area--matching-bracket { - background: $foreground 30%; + + &:dark { + .text-area--cursor { + color: $text 90%; + background: $foreground 90%; + } + &.-read-only .text-area--cursor { + background: $warning-darken-1; + } + } + + &:light { + .text-area--cursor { + color: $text 90%; + background: $foreground 70%; + } + &.-read-only .text-area--cursor { + background: $warning-darken-1; + } + } } """ @@ -295,6 +305,14 @@ class TextArea(ScrollView, can_focus=True): soft_wrap: Reactive[bool] = reactive(True, init=False) """True if text should soft wrap.""" + read_only: Reactive[bool] = reactive(False) + """True if the content is read-only. + + Read-only means end users cannot insert, delete or replace content. + + The document can still be edited programmatically via the API. + """ + _cursor_visible: Reactive[bool] = reactive(False, repaint=False, init=False) """Indicates where the cursor is in the blink cycle. If it's currently not visible due to blinking, this is False.""" @@ -337,6 +355,7 @@ def __init__( language: str | None = None, theme: str | None = None, soft_wrap: bool = True, + read_only: bool = False, tab_behaviour: Literal["focus", "indent"] = "focus", show_line_numbers: bool = False, name: str | None = None, @@ -351,7 +370,9 @@ def __init__( language: The language to use. theme: The theme to use. soft_wrap: Enable soft wrapping. - tab_behaviour: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab. + read_only: Enable read-only mode. This prevents edits using the keyboard. + tab_behaviour: If 'focus', pressing tab will switch focus. + If 'indent', pressing tab will insert a tab. show_line_numbers: Show line numbers on the left edge. name: The name of the `TextArea` widget. id: The ID of the widget, used to refer to it from Textual CSS. @@ -414,9 +435,9 @@ def __init__( self.theme = theme - self._reactive_soft_wrap = soft_wrap - - self._reactive_show_line_numbers = show_line_numbers + self.set_reactive(TextArea.soft_wrap, soft_wrap) + self.set_reactive(TextArea.read_only, read_only) + self.set_reactive(TextArea.show_line_numbers, show_line_numbers) self.tab_behaviour = tab_behaviour @@ -561,6 +582,10 @@ def _watch_cursor_blink(self, blink: bool) -> None: else: self._pause_blink(visible=self.has_focus) + def _watch_read_only(self, read_only: bool) -> None: + self.set_class(read_only, "-read-only") + self._set_theme(self._theme.name) + def _recompute_cursor_offset(self): """Recompute the (x, y) coordinate of the cursor in the wrapped document.""" self._cursor_offset = self.wrapped_document.location_to_offset( @@ -656,10 +681,10 @@ def _watch_theme(self, theme: str | None) -> None: if padding is applied, the colours match.""" self._set_theme(theme) - def _app_dark_toggled(self): + def _app_dark_toggled(self) -> None: self._set_theme(self._theme.name) - def _set_theme(self, theme: str | None): + def _set_theme(self, theme: str | None) -> None: theme_object: TextAreaTheme | None if theme is None: # If the theme is None, use the default. @@ -1219,6 +1244,10 @@ def edit(self, edit: Edit) -> EditResult: async def _on_key(self, event: events.Key) -> None: """Handle key presses which correspond to document inserts.""" + self._restart_blink() + if self.read_only: + return + key = event.key insert_values = { "enter": "\n", @@ -1234,7 +1263,6 @@ async def _on_key(self, event: events.Key) -> None: else: insert_values["tab"] = " " * self._find_columns_to_next_tab_stop() - self._restart_blink() if event.is_printable or key in insert_values: event.stop() event.prevent_default() @@ -1243,7 +1271,7 @@ async def _on_key(self, event: events.Key) -> None: # None because we've checked that it's printable. assert insert is not None start, end = self.selection - self.replace(insert, start, end, maintain_selection_offset=False) + self._replace_via_keyboard(insert, start, end) def _find_columns_to_next_tab_stop(self) -> int: """Get the location of the next tab stop after the cursors position on the current line. @@ -1310,6 +1338,11 @@ def _toggle_cursor_blink_visible(self) -> None: _, cursor_y = self._cursor_offset self.refresh_lines(cursor_y) + def _watch__cursor_visible(self) -> None: + """When the cursor visibility is toggled, ensure the row is refreshed.""" + _, cursor_y = self._cursor_offset + self.refresh_lines(cursor_y) + def _restart_blink(self) -> None: """Reset the cursor blink timer.""" if self.cursor_blink: @@ -1347,7 +1380,9 @@ async def _on_mouse_up(self, event: events.MouseUp) -> None: async def _on_paste(self, event: events.Paste) -> None: """When a paste occurs, insert the text from the paste event into the document.""" - result = self.replace(event.text, *self.selection) + if self.read_only: + return + result = self._replace_via_keyboard(event.text, *self.selection) self.move_cursor(result.end_location) def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int: @@ -1847,12 +1882,54 @@ def replace( """ return self.edit(Edit(insert, start, end, maintain_selection_offset)) - def clear(self) -> None: - """Delete all text from the document.""" + def clear(self) -> EditResult: + """Delete all text from the document. + + Returns: + An EditResult relating to the deletion of all content. + """ document = self.document last_line = document[-1] document_end = (document.line_count, len(last_line)) - self.delete((0, 0), document_end, maintain_selection_offset=False) + return self.delete((0, 0), document_end, maintain_selection_offset=False) + + def _delete_via_keyboard( + self, + start: Location, + end: Location, + ) -> EditResult | None: + """Handle a deletion performed using a keyboard (as opposed to the API). + + Args: + start: The start location of the text to delete. + end: The end location of the text to delete. + + Returns: + An EditResult or None if no edit was performed (e.g. on read-only mode). + """ + if self.read_only: + return None + return self.delete(start, end, maintain_selection_offset=False) + + def _replace_via_keyboard( + self, + insert: str, + start: Location, + end: Location, + ) -> EditResult | None: + """Handle a replacement performed using a keyboard (as opposed to the API). + + Args: + insert: The text to insert into the document. + start: The start location of the text to replace. + end: The end location of the text to replace. + + Returns: + An EditResult or None if no edit was performed (e.g. on read-only mode). + """ + if self.read_only: + return None + return self.replace(insert, start, end, maintain_selection_offset=False) def action_delete_left(self) -> None: """Deletes the character to the left of the cursor and updates the cursor location. @@ -1865,7 +1942,7 @@ def action_delete_left(self) -> None: if selection.is_empty: end = self.get_cursor_left_location() - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) def action_delete_right(self) -> None: """Deletes the character to the right of the cursor and keeps the cursor at the same location. @@ -1878,7 +1955,7 @@ def action_delete_right(self) -> None: if selection.is_empty: end = self.get_cursor_right_location() - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) def action_delete_line(self) -> None: """Deletes the lines which intersect with the selection.""" @@ -1895,20 +1972,21 @@ def action_delete_line(self) -> None: from_location = (start_row, 0) to_location = (end_row + 1, 0) - self.delete(from_location, to_location, maintain_selection_offset=False) - self.move_cursor_relative(columns=end_column, record_width=False) + deletion = self._delete_via_keyboard(from_location, to_location) + if deletion is not None: + self.move_cursor_relative(columns=end_column, record_width=False) def action_delete_to_start_of_line(self) -> None: """Deletes from the cursor location to the start of the line.""" from_location = self.selection.end to_location = self.get_cursor_line_start_location() - self.delete(from_location, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(from_location, to_location) def action_delete_to_end_of_line(self) -> None: """Deletes from the cursor location to the end of the line.""" from_location = self.selection.end to_location = self.get_cursor_line_end_location() - self.delete(from_location, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(from_location, to_location) def action_delete_word_left(self) -> None: """Deletes the word to the left of the cursor and updates the cursor location.""" @@ -1919,11 +1997,11 @@ def action_delete_word_left(self) -> None: # deletes the characters within the selection range, ignoring word boundaries. start, end = self.selection if start != end: - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) return to_location = self.get_cursor_word_left_location() - self.delete(self.selection.end, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(self.selection.end, to_location) def action_delete_word_right(self) -> None: """Deletes the word to the right of the cursor and keeps the cursor at the same location. @@ -1937,7 +2015,7 @@ def action_delete_word_right(self) -> None: start, end = self.selection if start != end: - self.delete(start, end, maintain_selection_offset=False) + self._delete_via_keyboard(start, end) return cursor_row, cursor_column = end @@ -1957,7 +2035,7 @@ def action_delete_word_right(self) -> None: else: to_location = (cursor_row, current_row_length) - self.delete(end, to_location, maintain_selection_offset=False) + self._delete_via_keyboard(end, to_location) @dataclass diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index f73d96309d..f4e6208590 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -36636,6 +36636,89 @@ ''' # --- +# name: test_text_area_read_only_cursor_rendering + ''' + + + + + + + + + + + + + + + + + + + + + + + TextAreaSnapshot + + + + + + + + + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + 1  Hello, world!           + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + + ''' +# --- # name: test_text_area_selection_rendering[selection0] ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 799027f92e..ceb08b05b6 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -879,6 +879,20 @@ def setup_selection(pilot): ) +def test_text_area_read_only_cursor_rendering(snap_compare): + def setup_selection(pilot): + text_area = pilot.app.query_one(TextArea) + text_area.theme = "css" + text_area.text = "Hello, world!" + text_area.read_only = True + + assert snap_compare( + SNAPSHOT_APPS_DIR / "text_area.py", + run_before=setup_selection, + terminal_size=(30, 5), + ) + + @pytest.mark.syntax @pytest.mark.parametrize( "theme_name", [theme.name for theme in TextAreaTheme.builtin_themes()] diff --git a/tests/text_area/test_edit_via_api.py b/tests/text_area/test_edit_via_api.py index 98072d35ae..e217bfa210 100644 --- a/tests/text_area/test_edit_via_api.py +++ b/tests/text_area/test_edit_via_api.py @@ -5,6 +5,7 @@ Note that more extensive testing for editing is done at the Document level. """ + import pytest from textual.app import App, ComposeResult @@ -521,10 +522,29 @@ async def test_replace_fully_within_selection(): ) assert text_area.selected_text == "XX56" + async def test_text_setter(): app = TextAreaApp() async with app.run_test(): text_area = app.query_one(TextArea) new_text = "hello\nworld\n" text_area.text = new_text - assert text_area.text == new_text \ No newline at end of file + assert text_area.text == new_text + + +async def test_edits_on_read_only_mode(): + """API edits should still be permitted on read-only mode.""" + app = TextAreaApp() + async with app.run_test(): + text_area = app.query_one(TextArea) + text_area.text = "0123456789" + text_area.read_only = True + + text_area.replace("X", (0, 1), (0, 5)) + assert text_area.text == "0X56789" + + text_area.insert("X") + assert text_area.text == "X0X56789" + + text_area.delete((0, 0), (0, 2)) + assert text_area.text == "X56789" diff --git a/tests/text_area/test_edit_via_bindings.py b/tests/text_area/test_edit_via_bindings.py index 2cd5a41114..17d7f52f02 100644 --- a/tests/text_area/test_edit_via_bindings.py +++ b/tests/text_area/test_edit_via_bindings.py @@ -420,6 +420,37 @@ async def test_delete_word_right_at_end_of_line(): assert text_area.selection == Selection.cursor((0, 5)) +@pytest.mark.parametrize( + "binding", + [ + "enter", + "backspace", + "ctrl+u", + "ctrl+f", + "ctrl+w", + "ctrl+k", + "ctrl+x", + "space", + "1", + "tab", + ], +) +async def test_edit_read_only_mode_does_nothing(binding): + """Try out various key-presses and bindings and ensure they don't alter + the document when read_only=True.""" + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.read_only = True + selection = Selection.cursor((0, 2)) + text_area.selection = selection + + await pilot.press(binding) + + assert text_area.text == TEXT + assert text_area.selection == selection + + @pytest.mark.parametrize( "selection", [ @@ -469,3 +500,15 @@ async def test_paste(selection): Z""" assert text_area.text == expected_text assert text_area.selection == Selection.cursor((1, 1)) + + +async def test_paste_read_only_does_nothing(): + app = TextAreaApp() + async with app.run_test() as pilot: + text_area = app.query_one(TextArea) + text_area.read_only = True + + app.post_message(Paste("hello")) + await pilot.pause() + + assert text_area.text == TEXT # No change diff --git a/tests/text_area/test_selection_bindings.py b/tests/text_area/test_selection_bindings.py index 7efdcef164..06b180485e 100644 --- a/tests/text_area/test_selection_bindings.py +++ b/tests/text_area/test_selection_bindings.py @@ -13,34 +13,41 @@ class TextAreaApp(App): + def __init__(self, read_only: bool = False): + super().__init__() + self.read_only = read_only + def compose(self) -> ComposeResult: - text_area = TextArea(show_line_numbers=True) - text_area.load_text(TEXT) - yield text_area + yield TextArea(TEXT, show_line_numbers=True, read_only=self.read_only) + +@pytest.fixture(params=[True, False]) +async def app(request): + """Each test that receives an `app` will execute twice. + Once with read_only=True, and once with read_only=False. + """ + return TextAreaApp(read_only=request.param) -async def test_mouse_click(): + +async def test_mouse_click(app: TextAreaApp): """When you click the TextArea, the cursor moves to the expected location.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.click(TextArea, Offset(x=5, y=2)) assert text_area.selection == Selection.cursor((1, 0)) -async def test_mouse_click_clamp_from_right(): +async def test_mouse_click_clamp_from_right(app: TextAreaApp): """When you click to the right of the document bounds, the cursor is clamped to within the document bounds.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.click(TextArea, Offset(x=8, y=20)) assert text_area.selection == Selection.cursor((4, 0)) -async def test_mouse_click_gutter_clamp(): +async def test_mouse_click_gutter_clamp(app: TextAreaApp): """When you click the gutter, it selects the start of the line.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.click(TextArea, Offset(x=0, y=3)) @@ -66,19 +73,17 @@ async def test_cursor_movement_basic(): assert text_area.selection == Selection.cursor((0, 0)) -async def test_cursor_selection_right(): +async def test_cursor_selection_right(app: TextAreaApp): """When you press shift+right the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) await pilot.press(*["shift+right"] * 3) assert text_area.selection == Selection((0, 0), (0, 3)) -async def test_cursor_selection_right_to_previous_line(): +async def test_cursor_selection_right_to_previous_line(app: TextAreaApp): """When you press shift+right resulting in the cursor moving to the next line, the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((0, 15)) @@ -86,9 +91,8 @@ async def test_cursor_selection_right_to_previous_line(): assert text_area.selection == Selection((0, 15), (1, 2)) -async def test_cursor_selection_left(): +async def test_cursor_selection_left(app: TextAreaApp): """When you press shift+left the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 5)) @@ -96,10 +100,9 @@ async def test_cursor_selection_left(): assert text_area.selection == Selection((2, 5), (2, 2)) -async def test_cursor_selection_left_to_previous_line(): +async def test_cursor_selection_left_to_previous_line(app: TextAreaApp): """When you press shift+left resulting in the cursor moving back to the previous line, the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 2)) @@ -110,9 +113,8 @@ async def test_cursor_selection_left_to_previous_line(): assert text_area.selection == Selection((2, 2), (1, end_of_previous_line)) -async def test_cursor_selection_up(): +async def test_cursor_selection_up(app: TextAreaApp): """When you press shift+up the selection is updated correctly.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((2, 3)) @@ -121,9 +123,8 @@ async def test_cursor_selection_up(): assert text_area.selection == Selection((2, 3), (1, 3)) -async def test_cursor_selection_up_when_cursor_on_first_line(): +async def test_cursor_selection_up_when_cursor_on_first_line(app: TextAreaApp): """When you press shift+up the on the first line, it selects to the start.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((0, 4)) @@ -134,8 +135,7 @@ async def test_cursor_selection_up_when_cursor_on_first_line(): assert text_area.selection == Selection((0, 4), (0, 0)) -async def test_cursor_selection_down(): - app = TextAreaApp() +async def test_cursor_selection_down(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((2, 5)) @@ -144,8 +144,7 @@ async def test_cursor_selection_down(): assert text_area.selection == Selection((2, 5), (3, 5)) -async def test_cursor_selection_down_when_cursor_on_last_line(): - app = TextAreaApp() +async def test_cursor_selection_down_when_cursor_on_last_line(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABCDEF\nGHIJK") @@ -157,8 +156,7 @@ async def test_cursor_selection_down_when_cursor_on_last_line(): assert text_area.selection == Selection((1, 2), (1, 5)) -async def test_cursor_word_right(): - app = TextAreaApp() +async def test_cursor_word_right(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -168,8 +166,7 @@ async def test_cursor_word_right(): assert text_area.selection == Selection.cursor((0, 3)) -async def test_cursor_word_right_select(): - app = TextAreaApp() +async def test_cursor_word_right_select(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -179,8 +176,7 @@ async def test_cursor_word_right_select(): assert text_area.selection == Selection((0, 0), (0, 3)) -async def test_cursor_word_left(): - app = TextAreaApp() +async def test_cursor_word_left(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -191,8 +187,7 @@ async def test_cursor_word_left(): assert text_area.selection == Selection.cursor((0, 4)) -async def test_cursor_word_left_select(): - app = TextAreaApp() +async def test_cursor_word_left_select(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("ABC DEF\nGHIJK") @@ -204,9 +199,8 @@ async def test_cursor_word_left_select(): @pytest.mark.parametrize("key", ["end", "ctrl+e"]) -async def test_cursor_to_line_end(key): +async def test_cursor_to_line_end(key, app: TextAreaApp): """You can use the keyboard to jump the cursor to the end of the current line.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 2)) @@ -217,9 +211,8 @@ async def test_cursor_to_line_end(key): @pytest.mark.parametrize("key", ["home", "ctrl+a"]) -async def test_cursor_to_line_home_basic_behaviour(key): +async def test_cursor_to_line_home_basic_behaviour(key, app: TextAreaApp): """You can use the keyboard to jump the cursor to the start of the current line.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.selection = Selection.cursor((2, 2)) @@ -239,11 +232,12 @@ async def test_cursor_to_line_home_basic_behaviour(key): ((0, 15), (0, 4)), ], ) -async def test_cursor_line_home_smart_home(cursor_start, cursor_destination): +async def test_cursor_line_home_smart_home( + cursor_start, cursor_destination, app: TextAreaApp +): """If the line begins with whitespace, pressing home firstly goes to the start of the (non-whitespace) content. Pressing it again takes you to column 0. If you press it again, it goes back to the first non-whitespace column.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text(" hello world") @@ -252,9 +246,8 @@ async def test_cursor_line_home_smart_home(cursor_start, cursor_destination): assert text_area.selection == Selection.cursor(cursor_destination) -async def test_cursor_page_down(): +async def test_cursor_page_down(app: TextAreaApp): """Pagedown moves the cursor down 1 page, retaining column index.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("XXX\n" * 200) @@ -266,9 +259,8 @@ async def test_cursor_page_down(): ) -async def test_cursor_page_up(): +async def test_cursor_page_up(app: TextAreaApp): """Pageup moves the cursor up 1 page, retaining column index.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.load_text("XXX\n" * 200) @@ -280,10 +272,9 @@ async def test_cursor_page_up(): ) -async def test_cursor_vertical_movement_visual_alignment_snapping(): +async def test_cursor_vertical_movement_visual_alignment_snapping(app: TextAreaApp): """When you move the cursor vertically, it should stay vertically aligned even when double-width characters are used.""" - app = TextAreaApp() async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.text = "こんにちは\n012345" @@ -301,8 +292,7 @@ async def test_cursor_vertical_movement_visual_alignment_snapping(): assert text_area.selection == Selection.cursor((1, 3)) -async def test_select_line_binding(): - app = TextAreaApp() +async def test_select_line_binding(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea) text_area.move_cursor((2, 2)) @@ -312,8 +302,7 @@ async def test_select_line_binding(): assert text_area.selection == Selection((2, 0), (2, 56)) -async def test_select_all_binding(): - app = TextAreaApp() +async def test_select_all_binding(app: TextAreaApp): async with app.run_test() as pilot: text_area = app.query_one(TextArea)