Skip to content

Commit d5ab640

Browse files
authored
Merge pull request #5934 from Textualize/show-cursor
Add show cursor boolean
2 parents db623fa + 0c78154 commit d5ab640

File tree

5 files changed

+436
-16
lines changed

5 files changed

+436
-16
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1010
### Added
1111

1212
- Added textual.getters https://github.com/Textualize/textual/pull/5930
13+
- Added a `show_cursor` boolean to TextArea https://github.com/Textualize/textual/pull/5934
1314

1415
### Changed
1516

1617
- Potential breaking change: Changed default `query_one` and `query_exactly_one` search to breadth first https://github.com/Textualize/textual/pull/5930
18+
- Cursor is now visible by default when in read only mode (restoring pre 3.6.0 behavior) https://github.com/Textualize/textual/pull/5934
1719

1820
## [3.6.0] - 2025-07-06
1921

src/textual/widgets/_text_area.py

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ class TextArea(ScrollView):
172172
&.-read-only .text-area--cursor {
173173
background: $warning-darken-1;
174174
}
175-
}
175+
}
176176
}
177177
"""
178178

@@ -373,6 +373,15 @@ class TextArea(ScrollView):
373373
The document can still be edited programmatically via the API.
374374
"""
375375

376+
show_cursor: Reactive[bool] = reactive(True)
377+
"""Show the cursor in read only mode?
378+
379+
If `True`, the cursor will be visible when `read_only==True`.
380+
If `False`, the cursor will be hidden when `read_only==True`, and the TextArea will
381+
scroll like other containers.
382+
383+
"""
384+
376385
compact: reactive[bool] = reactive(False, toggle_class="-textual-compact")
377386
"""Enable compact display?"""
378387

@@ -423,6 +432,7 @@ def __init__(
423432
soft_wrap: bool = True,
424433
tab_behavior: Literal["focus", "indent"] = "focus",
425434
read_only: bool = False,
435+
show_cursor: bool = True,
426436
show_line_numbers: bool = False,
427437
line_number_start: int = 1,
428438
max_checkpoints: int = 50,
@@ -443,6 +453,7 @@ def __init__(
443453
soft_wrap: Enable soft wrapping.
444454
tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab.
445455
read_only: Enable read-only mode. This prevents edits using the keyboard.
456+
show_cursor: Show the cursor in read only mode (no effect otherwise).
446457
show_line_numbers: Show line numbers on the left edge.
447458
line_number_start: What line number to start on.
448459
max_checkpoints: The maximum number of undo history checkpoints to retain.
@@ -509,6 +520,7 @@ def __init__(
509520

510521
self.set_reactive(TextArea.soft_wrap, soft_wrap)
511522
self.set_reactive(TextArea.read_only, read_only)
523+
self.set_reactive(TextArea.show_cursor, show_cursor)
512524
self.set_reactive(TextArea.show_line_numbers, show_line_numbers)
513525
self.set_reactive(TextArea.line_number_start, line_number_start)
514526
self.set_reactive(TextArea.highlight_cursor_line, highlight_cursor_line)
@@ -542,6 +554,7 @@ def code_editor(
542554
soft_wrap: bool = False,
543555
tab_behavior: Literal["focus", "indent"] = "indent",
544556
read_only: bool = False,
557+
show_cursor: bool = True,
545558
show_line_numbers: bool = True,
546559
line_number_start: int = 1,
547560
max_checkpoints: int = 50,
@@ -564,6 +577,8 @@ def code_editor(
564577
theme: The theme to use.
565578
soft_wrap: Enable soft wrapping.
566579
tab_behavior: If 'focus', pressing tab will switch focus. If 'indent', pressing tab will insert a tab.
580+
read_only: Enable read-only mode. This prevents edits using the keyboard.
581+
show_cursor: Show the cursor in read only mode (no effect otherwise).
567582
show_line_numbers: Show line numbers on the left edge.
568583
line_number_start: What line number to start on.
569584
name: The name of the `TextArea` widget.
@@ -581,6 +596,7 @@ def code_editor(
581596
soft_wrap=soft_wrap,
582597
tab_behavior=tab_behavior,
583598
read_only=read_only,
599+
show_cursor=show_cursor,
584600
show_line_numbers=show_line_numbers,
585601
line_number_start=line_number_start,
586602
max_checkpoints=max_checkpoints,
@@ -1101,6 +1117,24 @@ def _refresh_size(self) -> None:
11011117
width, height = self.document.get_size(self.indent_width)
11021118
self.virtual_size = Size(width + self.gutter_width + 1, height)
11031119

1120+
@property
1121+
def _draw_cursor(self) -> bool:
1122+
"""Draw the cursor?"""
1123+
if self.read_only:
1124+
# If we are in read only mode, we don't want the cursor to blink
1125+
return self.show_cursor and self.has_focus
1126+
draw_cursor = (
1127+
self.has_focus
1128+
and not self.cursor_blink
1129+
or (self.cursor_blink and self._cursor_visible)
1130+
)
1131+
return draw_cursor
1132+
1133+
@property
1134+
def _has_cursor(self) -> bool:
1135+
"""Is there a usable cursor?"""
1136+
return not (self.read_only and not self.show_cursor)
1137+
11041138
def get_line(self, line_index: int) -> Text:
11051139
"""Retrieve the line at the given line index.
11061140
@@ -1158,6 +1192,7 @@ def render_line(self, y: int) -> Strip:
11581192
self.soft_wrap,
11591193
self.show_line_numbers,
11601194
self.read_only,
1195+
self.show_cursor,
11611196
)
11621197
if (cached_line := self._line_cache.get(cache_key)) is not None:
11631198
return cached_line
@@ -1213,12 +1248,13 @@ def _render_line(self, y: int) -> Strip:
12131248
selection_top_row, selection_top_column = selection_top
12141249
selection_bottom_row, selection_bottom_column = selection_bottom
12151250

1216-
cursor_line_style = theme.cursor_line_style if theme else None
1217-
if (
1218-
cursor_line_style
1219-
and cursor_row == line_index
1220-
and self.highlight_cursor_line
1221-
):
1251+
highlight_cursor_line = self.highlight_cursor_line and self._has_cursor
1252+
cursor_line_style = (
1253+
theme.cursor_line_style if (theme and highlight_cursor_line) else None
1254+
)
1255+
has_cursor = self._has_cursor
1256+
1257+
if has_cursor and cursor_line_style and cursor_row == line_index:
12221258
line.stylize(cursor_line_style)
12231259

12241260
# Selection styling
@@ -1271,16 +1307,14 @@ def _render_line(self, y: int) -> Strip:
12711307
matching_bracket = self._matching_bracket_location
12721308
match_cursor_bracket = self.match_cursor_bracket
12731309
draw_matched_brackets = (
1274-
match_cursor_bracket and matching_bracket is not None and start == end
1310+
has_cursor
1311+
and match_cursor_bracket
1312+
and matching_bracket is not None
1313+
and start == end
12751314
)
12761315

12771316
if cursor_row == line_index:
1278-
draw_cursor = (
1279-
self.has_focus
1280-
and not self.cursor_blink
1281-
or (self.cursor_blink and self._cursor_visible)
1282-
and not self.read_only
1283-
)
1317+
draw_cursor = self._draw_cursor
12841318
if draw_matched_brackets:
12851319
matching_bracket_style = theme.bracket_matching_style if theme else None
12861320
if matching_bracket_style:
@@ -1312,7 +1346,7 @@ def _render_line(self, y: int) -> Strip:
13121346
# Build the gutter text for this line
13131347
gutter_width = self.gutter_width
13141348
if self.show_line_numbers:
1315-
if cursor_row == line_index and self.highlight_cursor_line:
1349+
if cursor_row == line_index and highlight_cursor_line:
13161350
gutter_style = theme.cursor_line_gutter_style
13171351
else:
13181352
gutter_style = theme.gutter_style
@@ -1564,7 +1598,9 @@ def _redo_batch(self, edits: Sequence[Edit]) -> None:
15641598

15651599
async def _on_key(self, event: events.Key) -> None:
15661600
"""Handle key presses which correspond to document inserts."""
1601+
15671602
self._restart_blink()
1603+
15681604
if self.read_only:
15691605
return
15701606

@@ -1775,6 +1811,8 @@ def scroll_cursor_visible(
17751811
Returns:
17761812
The offset that was scrolled to bring the cursor into view.
17771813
"""
1814+
if not self._has_cursor:
1815+
return Offset(0, 0)
17781816
self._recompute_cursor_offset()
17791817

17801818
x, y = self._cursor_offset
@@ -1804,6 +1842,8 @@ def move_cursor(
18041842
so that we jump back to the same width the next time we move to a row
18051843
that is wide enough.
18061844
"""
1845+
if not self._has_cursor:
1846+
return
18071847
if select:
18081848
start, _end = self.selection
18091849
self.selection = Selection(start, location)
@@ -1948,6 +1988,9 @@ def action_cursor_left(self, select: bool = False) -> None:
19481988
Args:
19491989
select: If True, select the text while moving.
19501990
"""
1991+
if not self._has_cursor:
1992+
self.scroll_left()
1993+
return
19511994
target = (
19521995
self.get_cursor_left_location()
19531996
if select or self.selection.is_empty
@@ -1973,6 +2016,9 @@ def action_cursor_right(self, select: bool = False) -> None:
19732016
Args:
19742017
select: If True, select the text while moving.
19752018
"""
2019+
if not self._has_cursor:
2020+
self.scroll_right()
2021+
return
19762022
target = (
19772023
self.get_cursor_right_location()
19782024
if select or self.selection.is_empty
@@ -1994,6 +2040,9 @@ def action_cursor_down(self, select: bool = False) -> None:
19942040
Args:
19952041
select: If True, select the text while moving.
19962042
"""
2043+
if not self._has_cursor:
2044+
self.scroll_down()
2045+
return
19972046
target = self.get_cursor_down_location()
19982047
self.move_cursor(target, record_width=False, select=select)
19992048

@@ -2011,6 +2060,9 @@ def action_cursor_up(self, select: bool = False) -> None:
20112060
Args:
20122061
select: If True, select the text while moving.
20132062
"""
2063+
if not self._has_cursor:
2064+
self.scroll_up()
2065+
return
20142066
target = self.get_cursor_up_location()
20152067
self.move_cursor(target, record_width=False, select=select)
20162068

@@ -2024,6 +2076,9 @@ def get_cursor_up_location(self) -> Location:
20242076

20252077
def action_cursor_line_end(self, select: bool = False) -> None:
20262078
"""Move the cursor to the end of the line."""
2079+
if not self._has_cursor:
2080+
self.scroll_end()
2081+
return
20272082
location = self.get_cursor_line_end_location()
20282083
self.move_cursor(location, select=select)
20292084

@@ -2037,6 +2092,9 @@ def get_cursor_line_end_location(self) -> Location:
20372092

20382093
def action_cursor_line_start(self, select: bool = False) -> None:
20392094
"""Move the cursor to the start of the line."""
2095+
if not self._has_cursor:
2096+
self.scroll_home()
2097+
return
20402098
target = self.get_cursor_line_start_location(smart_home=True)
20412099
self.move_cursor(target, select=select)
20422100

@@ -2061,6 +2119,8 @@ def action_cursor_word_left(self, select: bool = False) -> None:
20612119
Args:
20622120
select: Whether to select while moving the cursor.
20632121
"""
2122+
if not self.show_cursor:
2123+
return
20642124
if self.cursor_at_start_of_text:
20652125
return
20662126
target = self.get_cursor_word_left_location()
@@ -2086,7 +2146,8 @@ def get_cursor_word_left_location(self) -> Location:
20862146

20872147
def action_cursor_word_right(self, select: bool = False) -> None:
20882148
"""Move the cursor right by a single word, skipping leading whitespace."""
2089-
2149+
if not self.show_cursor:
2150+
return
20902151
if self.cursor_at_end_of_text:
20912152
return
20922153

@@ -2121,6 +2182,9 @@ def get_cursor_word_right_location(self) -> Location:
21212182

21222183
def action_cursor_page_up(self) -> None:
21232184
"""Move the cursor and scroll up one page."""
2185+
if not self.show_cursor:
2186+
self.scroll_page_up()
2187+
return
21242188
height = self.content_size.height
21252189
_, cursor_location = self.selection
21262190
target = self.navigator.get_location_at_y_offset(
@@ -2132,6 +2196,9 @@ def action_cursor_page_up(self) -> None:
21322196

21332197
def action_cursor_page_down(self) -> None:
21342198
"""Move the cursor and scroll down one page."""
2199+
if not self.show_cursor:
2200+
self.scroll_page_down()
2201+
return
21352202
height = self.content_size.height
21362203
_, cursor_location = self.selection
21372204
target = self.navigator.get_location_at_y_offset(
@@ -2289,6 +2356,9 @@ def action_delete_left(self) -> None:
22892356
22902357
If there's a selection, then the selected range is deleted."""
22912358

2359+
if self.read_only:
2360+
return
2361+
22922362
selection = self.selection
22932363
start, end = selection
22942364

@@ -2301,6 +2371,8 @@ def action_delete_right(self) -> None:
23012371
"""Deletes the character to the right of the cursor and keeps the cursor at the same location.
23022372
23032373
If there's a selection, then the selected range is deleted."""
2374+
if self.read_only:
2375+
return
23042376

23052377
selection = self.selection
23062378
start, end = selection
@@ -2312,6 +2384,8 @@ def action_delete_right(self) -> None:
23122384

23132385
def action_delete_line(self) -> None:
23142386
"""Deletes the lines which intersect with the selection."""
2387+
if self.read_only:
2388+
return
23152389
self._delete_cursor_line()
23162390

23172391
def _delete_cursor_line(self) -> EditResult | None:

0 commit comments

Comments
 (0)