Skip to content

Commit

Permalink
Text area read only (#4151)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
darrenburns authored Feb 13, 2024
1 parent db4760b commit e6ad1bd
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 106 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/textual/_text_area_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down
6 changes: 5 additions & 1 deletion src/textual/document/_document_navigator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down
186 changes: 132 additions & 54 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
"""

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand All @@ -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()
Expand All @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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."""
Expand All @@ -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."""
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading

0 comments on commit e6ad1bd

Please sign in to comment.