Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Text area read only #4151

Merged
merged 19 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

darrenburns marked this conversation as resolved.
Show resolved Hide resolved
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
163 changes: 110 additions & 53 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,33 @@ def replace(
"""
return self.edit(Edit(insert, start, end, maintain_selection_offset))

def clear(self) -> None:
def clear(self) -> EditResult:
"""Delete all text from the document."""
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
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)."""
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
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)."""
darrenburns marked this conversation as resolved.
Show resolved Hide resolved
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 +1921,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 +1934,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 +1951,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 +1976,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 +1994,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 +2014,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
Loading