Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## Changed

- Widget.release_mouse will now only release the mouse, if it was captured by self https://github.com/Textualize/textual/pull/5900
- Some optimizations to TextArea, which may be noticeable during scrolling (note: may break snapshots with a TextArea) https://github.com/Textualize/textual/pull/5925
- Selecting in the TextArea now hides the cursor until you release the mouse https://github.com/Textualize/textual/pull/5925

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we also still want the cursor here, as it helps show what exactly is being selected?

For example, try selecting a line with the mouse, starting at the end of the line. If you select only until the start of that line and hit backspace, that leaves a blank line. Whereas if you select until the end of the previous line, the line is removed entirely.

When you're quickly trying to edit, after selecting the text you're probably almost immediately going to hit the next key, which is why I think the cursor context while selecting is important.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't follow. The cursor is always directly under the mouse, which is why it feels redundant. As soon as you release the mouse the cursor reappears.

Screen.Recording.2025-07-06.at.08.15.17.mov

I'm going to merge this shortly. But if I've missed something, I'm happy to revisit it.

- Read only TextAreas will no longer display a cursor https://github.com/Textualize/textual/pull/5925

@TomJGooding TomJGooding Jul 5, 2025

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can still navigate and select the text in read-only mode, so don't we need the cursor?


## Added

Expand Down
5 changes: 5 additions & 0 deletions src/textual/document/_document.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,8 @@ def is_empty(self) -> bool:
"""Return True if the selection has 0 width, i.e. it's just a cursor."""
start, end = self
return start == end

def contains_line(self, y: int) -> bool:
"""Check if the given line is within the selection."""
top, bottom = sorted((self.start[0], self.end[0]))
return y >= top and y <= bottom
91 changes: 70 additions & 21 deletions src/textual/widgets/_text_area.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@
from typing import TYPE_CHECKING, ClassVar, Iterable, Optional, Sequence, Tuple

from rich.console import RenderableType
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from typing_extensions import Literal

from textual._text_area_theme import TextAreaTheme
from textual._tree_sitter import TREE_SITTER, get_language
from textual.cache import LRUCache
from textual.color import Color
from textual.document._document import (
Document,
Expand Down Expand Up @@ -511,6 +513,8 @@ def __init__(
self.set_reactive(TextArea.line_number_start, line_number_start)
self.set_reactive(TextArea.highlight_cursor_line, highlight_cursor_line)

self._line_cache: LRUCache[tuple, Strip] = LRUCache(1024)

self._set_document(text, language)

self.language = language
Expand Down Expand Up @@ -610,6 +614,9 @@ def _get_builtin_highlight_query(language_name: str) -> str:

return highlight_query

def notify_style_update(self) -> None:
self._line_cache.clear()

def check_consume_key(self, key: str, character: str | None = None) -> bool:
"""Check if the widget may consume the given key.

Expand All @@ -633,6 +640,7 @@ def check_consume_key(self, key: str, character: str | None = None) -> bool:

def _build_highlight_map(self) -> None:
"""Query the tree for ranges to highlights, and update the internal highlights mapping."""
self._line_cache.clear()
highlights = self._highlights
highlights.clear()
if not self._highlight_query:
Expand Down Expand Up @@ -1035,6 +1043,7 @@ def wrap_width(self) -> int:

def _rewrap_and_refresh_virtual_size(self) -> None:
self.wrapped_document.wrap(self.wrap_width, tab_width=self.indent_width)
self._line_cache.clear()
self._refresh_size()

@property
Expand Down Expand Up @@ -1105,20 +1114,67 @@ def get_line(self, line_index: int) -> Text:
A `rich.Text` object containing the requested line.
"""
line_string = self.document.get_line(line_index)
return Text(line_string, end="")
return Text(line_string, end="", no_wrap=True)

def render_lines(self, crop: Region) -> list[Strip]:
theme = self._theme
if theme:
theme.apply_css(self)
return super().render_lines(crop)

def render_line(self, y: int) -> Strip:
"""Render a single line of the TextArea. Called by Textual.

Args:
y: Y Coordinate of line relative to the widget region.

Returns:
A rendered line.
"""
scroll_x, scroll_y = self.scroll_offset
absolute_y = scroll_y + y
selection = self.selection
cache_key = (
self.size,
scroll_x,
absolute_y,
(
selection
if selection.contains_line(absolute_y)
else selection.end[0] == absolute_y
),
(
selection.end
if (
self._cursor_visible
and self.cursor_blink
and absolute_y == selection.end[0]
)
else None
),
self.theme,
self._matching_bracket_location,
self.match_cursor_bracket,
self.soft_wrap,
self.show_line_numbers,
self.read_only,
)
if (cached_line := self._line_cache.get(cache_key)) is not None:
return cached_line
line = self._render_line(y)
self._line_cache[cache_key] = line
return line

def _render_line(self, y: int) -> Strip:
"""Render a single line of the TextArea. Called by Textual.

Args:
y: Y Coordinate of line relative to the widget region.

Returns:
A rendered line.
"""
theme = self._theme
if theme:
theme.apply_css(self)

wrapped_document = self.wrapped_document
scroll_x, scroll_y = self.scroll_offset
Expand Down Expand Up @@ -1173,7 +1229,8 @@ def render_line(self, y: int) -> Strip:
if selection_style:
if line_character_count == 0 and line_index != cursor_row:
# A simple highlight to show empty lines are included in the selection
line = Text("▌", end="", style=Style(color=selection_style.bgcolor))
line.plain = "▌"
line.stylize(Style(color=selection_style.bgcolor))
else:
if line_index == selection_top_row == selection_bottom_row:
# Selection within a single line
Expand Down Expand Up @@ -1222,6 +1279,7 @@ def render_line(self, y: int) -> Strip:
self.has_focus
and not self.cursor_blink
or (self.cursor_blink and self._cursor_visible)
and not self.read_only
)
if draw_matched_brackets:
matching_bracket_style = theme.bracket_matching_style if theme else None
Expand Down Expand Up @@ -1263,13 +1321,11 @@ def render_line(self, y: int) -> Strip:
gutter_content = (
str(line_index + self.line_number_start) if section_offset == 0 else ""
)
gutter = Text(
f"{gutter_content:>{gutter_width_no_margin}} ",
style=gutter_style or "",
end="",
)
gutter = [
Segment(f"{gutter_content:>{gutter_width_no_margin}} ", gutter_style)
]
else:
gutter = Text("", end="")
gutter = []

# TODO: Lets not apply the division each time through render_line.
# We should cache sections with the edit counts.
Expand Down Expand Up @@ -1304,17 +1360,10 @@ def render_line(self, y: int) -> Strip:
else max(virtual_width, self.region.size.width)
)
target_width = base_width - self.gutter_width
console = self.app.console
gutter_segments = console.render(gutter)

text_segments = list(
console.render(line, console.options.update_width(target_width))
)

gutter_strip = Strip(gutter_segments, cell_length=gutter_width)
text_strip = Strip(text_segments)

# Crop the line to show only the visible part (some may be scrolled out of view)
console = self.app.console
text_strip = Strip(line.render(console), cell_length=line.cell_len)
if not self.soft_wrap:
text_strip = text_strip.crop(scroll_x, scroll_x + virtual_width)

Expand All @@ -1325,7 +1374,7 @@ def render_line(self, y: int) -> Strip:
line_style = theme.base_style if theme else None

text_strip = text_strip.extend_cell_length(target_width, line_style)
strip = Strip.join([gutter_strip, text_strip]).simplify()
strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip])

return strip.apply_style(
theme.base_style
Expand Down Expand Up @@ -1645,7 +1694,7 @@ async def _on_mouse_down(self, event: events.MouseDown) -> None:
# Capture the mouse so that if the cursor moves outside the
# TextArea widget while selecting, the widget still scrolls.
self.capture_mouse()
self._pause_blink(visible=True)
self._pause_blink(visible=False)
self.history.checkpoint()

async def _on_mouse_move(self, event: events.MouseMove) -> None:
Expand Down
Loading
Loading