TextArea default CSS (#4074)

* Starting CSS work for TextArea

* Remove xfail marker from test

* Adding component classes to TextArea, not using them yet

* Adding docstring for TextArea new component classes

* Passing all component styles to the theme so that they may be applied.

* Applying cursor component style

* Applying text-area--cursor-line component style

* Applying text-area--cursor-gutter component style

* Applying gutter cursor style correctly

* Default cursor styling

* CSS theming of the selection style

* default matching bracket theme in text area

* Support toggling dark and light mode

* Improve the theme on light mode for the cursor

* null check

* Snapshot for new default "css" theme of TextArea

* Hide cursor when TextArea doesnt have focus

* Some new docs for TextArea

* Add border to TextArea to fit more with Input

* Add note on how to remove the focus border effect

* Updating snapshots

* Updating snapshots

* Fixing tests to account for new TextArea border

* Fix a typo

* Updating CHANGELOG

* Update docs/widgets/text_area.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Add missing docstring

---------

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
Darren Burns
2024-01-31 11:15:54 +00:00
committed by GitHub
parent 95e05927af
commit f017604cfc
8 changed files with 1884 additions and 1424 deletions

View File

@@ -12,7 +12,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Breaking change: Significant changes to `TextArea.__init__` default values/behaviour https://github.com/Textualize/textual/pull/3933
- `soft_wrap=True` - soft wrapping is now enabled by default.
- `show_line_numbers=False` - line numbers are now disabled by default.
- `tab_behaviour="focus"` - pressing the tab key now switches focus instead of indenting by default.
- `tab_behaviour="focus"` - pressing the tab key now switches focus instead of indenting by default.
- Breaking change: `TextArea` default theme changed to CSS, and default styling changed https://github.com/Textualize/textual/pull/4074
- Breaking change: `DOMNode.has_pseudo_class` now accepts a single name only https://github.com/Textualize/textual/pull/3970
- Made `textual.cache` (formerly `textual._cache`) public https://github.com/Textualize/textual/pull/3976
- `Tab.label` can now be used to change the label of a tab https://github.com/Textualize/textual/pull/3979
@@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `TextArea.code_editor` classmethod/alternative constructor https://github.com/Textualize/textual/pull/3933
- Added `TextArea.wrapped_document` attribute which can convert between wrapped visual coordinates and locations https://github.com/Textualize/textual/pull/3933
- Added `show_line_numbers` to `TextArea.__init__` https://github.com/Textualize/textual/pull/3933
- Added component classes allowing `TextArea` to be styled using CSS https://github.com/Textualize/textual/pull/4074
- Added `Query.blur` and `Query.focus` https://github.com/Textualize/textual/pull/4012
- Added `MessagePump.message_queue_size` https://github.com/Textualize/textual/pull/4012
- Added `TabbedContent.active_pane` https://github.com/Textualize/textual/pull/4012

View File

@@ -173,6 +173,17 @@ There are some methods available which make common selections easier:
Themes give you control over the look and feel, including syntax highlighting,
the cursor, selection, gutter, and more.
#### Default theme
The default `TextArea` theme is called `css`.
This a theme which takes values entirely from CSS.
This means that the default appearance of the widget fits nicely into a standard Textual application,
and looks right on both dark and light mode.
More complex applications such as code editors will likely want to use pre-defined themes such as `monokai`.
This involves using a `TextAreaTheme` object, which we cover in detail below.
This allows full customization of the `TextArea`, including syntax highlighting, at the code level.
#### Using builtin themes
The initial theme of the `TextArea` is determined by the `theme` parameter.
@@ -232,6 +243,7 @@ my_theme = TextAreaTheme(
Attributes like `cursor_style` and `cursor_line_style` apply general language-agnostic
styling to the widget.
If you choose not to supply a value for one of these attributes, it will be taken from the CSS component styles.
The `syntax_styles` attribute of `TextAreaTheme` is used for syntax highlighting and
depends on the `language` currently in use.
@@ -485,9 +497,13 @@ The `TextArea` widget defines the following bindings:
## Component classes
The `TextArea` widget defines no component classes.
The `TextArea` defines component classes that can style various aspects of the widget.
Styles from the `theme` attribute take priority.
Styling should be done exclusively via [`TextAreaTheme`][textual.widgets.text_area.TextAreaTheme].
::: textual.widgets.TextArea.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
## See also
@@ -499,6 +515,11 @@ Styling should be done exclusively via [`TextAreaTheme`][textual.widgets.text_ar
- The tree-sitter Python bindings [repository](https://github.com/tree-sitter/py-tree-sitter).
- `py-tree-sitter-languages` [repository](https://github.com/grantjenks/py-tree-sitter-languages) (provides binary wheels for a large variety of tree-sitter languages).
## Additional notes
- To remove the outline effect when the `TextArea` is focused, you can set `border: none; padding: 0;` in your CSS.
---
::: textual.widgets._text_area.TextArea

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from rich.style import Style
@@ -8,6 +9,9 @@ from textual.app import DEFAULT_COLORS
from textual.color import Color
from textual.design import DEFAULT_DARK_SURFACE
if TYPE_CHECKING:
from textual.widgets import TextArea
@dataclass
class TextAreaTheme:
@@ -63,10 +67,18 @@ class TextAreaTheme:
syntax_styles: dict[str, Style] = field(default_factory=dict)
"""The mapping of tree-sitter names from the `highlight_query` to Rich styles."""
def __post_init__(self) -> None:
"""Generate some styles if they haven't been supplied."""
if self.base_style is None:
self.base_style = Style()
def apply_css(self, text_area: TextArea) -> None:
"""Apply CSS rules from a TextArea to be used for fallback styling.
If any attributes in the theme aren't supplied, they'll be filled with the appropriate
base CSS (e.g. color, background, etc.) and component CSS (e.g. text-area--cursor) from
the supplied TextArea.
Args:
text_area: The TextArea instance to retrieve fallback styling from.
"""
self.base_style = text_area.rich_style or Style()
get_style = text_area.get_component_rich_style
if self.base_style.color is None:
self.base_style = Style(color="#f3f3f3", bgcolor=self.base_style.bgcolor)
@@ -81,33 +93,58 @@ class TextAreaTheme:
assert self.base_style.bgcolor is not None
if self.gutter_style is None:
self.gutter_style = self.base_style.copy()
gutter_style = get_style("text-area--gutter")
if gutter_style:
self.gutter_style = gutter_style
else:
self.gutter_style = self.base_style.copy()
background_color = Color.from_rich_color(self.base_style.bgcolor)
if self.cursor_style is None:
self.cursor_style = Style(
color=background_color.rich_color,
bgcolor=background_color.inverse.rich_color,
)
# If the theme doesn't contain a cursor style, fallback to component styles.
cursor_style = get_style("text-area--cursor")
if cursor_style:
self.cursor_style = cursor_style
else:
# There's no component style either, fallback to a default.
self.cursor_style = Style(
color=background_color.rich_color,
bgcolor=background_color.inverse.rich_color,
)
# Apply fallbacks for the styles of the active line and active line gutter.
if self.cursor_line_style is None:
self.cursor_line_style = get_style("text-area--cursor-line")
if self.cursor_line_gutter_style is None:
self.cursor_line_gutter_style = get_style("text-area--cursor-gutter")
if self.cursor_line_gutter_style is None and self.cursor_line_style is not None:
self.cursor_line_gutter_style = self.cursor_line_style.copy()
if self.bracket_matching_style is None:
bracket_matching_background = background_color.blend(
background_color.inverse, factor=0.05
)
self.bracket_matching_style = Style(
bgcolor=bracket_matching_background.rich_color
)
matching_bracket_style = get_style("text-area--matching-bracket")
if matching_bracket_style:
self.bracket_matching_style = matching_bracket_style
else:
bracket_matching_background = background_color.blend(
background_color.inverse, factor=0.05
)
self.bracket_matching_style = Style(
bgcolor=bracket_matching_background.rich_color
)
if self.selection_style is None:
selection_background_color = background_color.blend(
DEFAULT_COLORS["dark"].primary, factor=0.75
)
self.selection_style = Style.from_color(
bgcolor=selection_background_color.rich_color
)
selection_style = get_style("text-area--selection")
if selection_style:
self.selection_style = selection_style
else:
selection_background_color = background_color.blend(
DEFAULT_COLORS["dark"].primary, factor=0.75
)
self.selection_style = Style.from_color(
bgcolor=selection_background_color.rich_color
)
@classmethod
def get_builtin_theme(cls, theme_name: str) -> TextAreaTheme | None:
@@ -342,12 +379,15 @@ _GITHUB_LIGHT = TextAreaTheme(
},
)
_CSS_THEME = TextAreaTheme(name="css")
_BUILTIN_THEMES = {
"css": _CSS_THEME,
"monokai": _MONOKAI,
"dracula": _DRACULA,
"vscode_dark": _DARK_VS,
"github_light": _GITHUB_LIGHT,
}
DEFAULT_THEME = TextAreaTheme.get_builtin_theme("monokai")
DEFAULT_THEME = TextAreaTheme.get_builtin_theme("basic")
"""The default TextAreaTheme used by Textual."""

View File

@@ -1,11 +1,12 @@
from __future__ import annotations
import dataclasses
import re
from collections import defaultdict
from dataclasses import dataclass, field
from functools import lru_cache
from pathlib import Path
from typing import TYPE_CHECKING, Any, Iterable, Optional, Tuple
from typing import TYPE_CHECKING, Any, ClassVar, Iterable, Optional, Tuple
from rich.style import Style
from rich.text import Text
@@ -87,9 +88,70 @@ class TextArea(ScrollView, can_focus=True):
TextArea {
width: 1fr;
height: 1fr;
border: tall $background;
padding: 0 1;
&: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%;
}
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"text-area--cursor",
"text-area--gutter",
"text-area--cursor-gutter",
"text-area--cursor-line",
"text-area--selection",
"text-area--matching-bracket",
}
"""
`TextArea` offers some component classes which can be used to style aspects of the widget.
Note that any attributes provided in the chosen `TextAreaTheme` will take priority here.
| Class | Description |
| :- | :- |
| `text-area--cursor` | Target the cursor. |
| `text-area--gutter` | Target the gutter (line number column). |
| `text-area--cursor-gutter` | Target the gutter area of the line the cursor is on. |
| `text-area--cursor-line` | Target the line the cursor is on. |
| `text-area--selection` | Target the current selection. |
| `text-area--matching-bracket` | Target matching brackets. |
"""
BINDINGS = [
Binding("escape", "screen.focus_next", "Shift Focus", show=False),
# Cursor movement
@@ -235,7 +297,7 @@ TextArea {
soft_wrap: Reactive[bool] = reactive(True, init=False)
"""True if text should soft wrap."""
_cursor_blink_visible: Reactive[bool] = reactive(True, repaint=False, init=False)
_cursor_visible: Reactive[bool] = reactive(True, 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."""
@@ -360,6 +422,9 @@ TextArea {
self.tab_behaviour = tab_behaviour
# When `app.dark` is toggled, reset the theme (since it caches values).
self.watch(self.app, "dark", self._app_dark_toggled, init=False)
@classmethod
def code_editor(
cls,
@@ -455,6 +520,10 @@ TextArea {
# Add the last line of the node range
highlights[node_end_row].append((0, node_end_column, highlight_name))
def watch_has_focus(self, value: bool) -> None:
self._cursor_visible = value
super().watch_has_focus(value)
def _watch_selection(
self, previous_selection: Selection, selection: Selection
) -> None:
@@ -575,7 +644,12 @@ TextArea {
def _watch_theme(self, theme: str | None) -> None:
"""We set the styles on this widget when the theme changes, to ensure that
if padding is applied, the colours match."""
self._set_theme(theme)
def _app_dark_toggled(self):
self._set_theme(self._theme.name)
def _set_theme(self, theme: str | None):
theme_object: TextAreaTheme | None
if theme is None:
# If the theme is None, use the default.
@@ -594,7 +668,7 @@ TextArea {
f"then switch to that theme by setting the `TextArea.theme` attribute."
)
self._theme = theme_object
self._theme = dataclasses.replace(theme_object)
if theme_object:
base_style = theme_object.base_style
if base_style:
@@ -855,6 +929,10 @@ TextArea {
Returns:
A rendered line.
"""
theme = self._theme
if theme:
theme.apply_css(self)
document = self.document
wrapped_document = self.wrapped_document
scroll_x, scroll_y = self.scroll_offset
@@ -879,8 +957,6 @@ TextArea {
line_index, section_offset = line_info
theme = self._theme
# Get the line from the Document.
line_string = document.get_line(line_index)
line = Text(line_string, end="")
@@ -956,7 +1032,7 @@ TextArea {
if cursor_row == line_index:
draw_cursor = not self.cursor_blink or (
self.cursor_blink and self._cursor_blink_visible
self.cursor_blink and self._cursor_visible
)
if draw_matched_brackets:
matching_bracket_style = theme.bracket_matching_style if theme else None
@@ -990,9 +1066,9 @@ TextArea {
gutter_width = self.gutter_width
if self.show_line_numbers:
if cursor_row == line_index:
gutter_style = theme.cursor_line_gutter_style if theme else None
gutter_style = theme.cursor_line_gutter_style
else:
gutter_style = theme.gutter_style if theme else None
gutter_style = theme.gutter_style
gutter_width_no_margin = gutter_width - 2
gutter_content = str(line_index + 1) if section_offset == 0 else ""
@@ -1221,19 +1297,19 @@ TextArea {
def _toggle_cursor_blink_visible(self) -> None:
"""Toggle visibility of the cursor for the purposes of 'cursor blink'."""
self._cursor_blink_visible = not self._cursor_blink_visible
self._cursor_visible = not self._cursor_visible
_, cursor_y = self._cursor_offset
self.refresh_lines(cursor_y)
def _restart_blink(self) -> None:
"""Reset the cursor blink timer."""
if self.cursor_blink:
self._cursor_blink_visible = True
self._cursor_visible = True
self.blink_timer.reset()
def _pause_blink(self, visible: bool = True) -> None:
"""Pause the cursor blinking but ensure it stays visible."""
self._cursor_blink_visible = visible
self._cursor_visible = visible
self.blink_timer.pause()
async def _on_mouse_down(self, event: events.MouseDown) -> None:

File diff suppressed because one or more lines are too long

View File

@@ -811,7 +811,7 @@ def test_text_area_language_rendering(language, snap_compare):
assert snap_compare(
SNAPSHOT_APPS_DIR / "text_area.py",
run_before=setup_language,
terminal_size=(80, snippet.count("\n") + 2),
terminal_size=(80, snippet.count("\n") + 4),
)
@@ -842,7 +842,7 @@ I am the final line."""
assert snap_compare(
SNAPSHOT_APPS_DIR / "text_area.py",
run_before=setup_selection,
terminal_size=(30, text.count("\n") + 1),
terminal_size=(30, text.count("\n") + 4),
)
@@ -872,7 +872,7 @@ def hello(name):
assert snap_compare(
SNAPSHOT_APPS_DIR / "text_area.py",
run_before=setup_theme,
terminal_size=(48, text.count("\n") + 2),
terminal_size=(48, text.count("\n") + 4),
)

View File

@@ -307,15 +307,15 @@ async def test_cursor_screen_offset_and_terminal_cursor_position_update():
async with app.run_test():
text_area = app.query_one(TextArea)
assert app.cursor_position == (3, 0)
assert app.cursor_position == (5, 1)
text_area.cursor_location = (1, 1)
assert text_area.cursor_screen_offset == (4, 1)
assert text_area.cursor_screen_offset == (6, 2)
# Also ensure that this update has been reported back to the app
# for the benefit of IME/emoji popups.
assert app.cursor_position == (4, 1)
assert app.cursor_position == (6, 2)
async def test_cursor_screen_offset_and_terminal_cursor_position_scrolling():
@@ -327,10 +327,10 @@ async def test_cursor_screen_offset_and_terminal_cursor_position_scrolling():
async with app.run_test(size=(80, 2)) as pilot:
text_area = app.query_one(TextArea)
assert app.cursor_position == (3, 0)
assert app.cursor_position == (5, 1)
text_area.cursor_location = (5, 0)
await pilot.pause()
assert text_area.cursor_screen_offset == (3, 1)
assert app.cursor_position == (3, 1)
assert text_area.cursor_screen_offset == (5, 1)
assert app.cursor_position == (5, 1)

View File

@@ -3,7 +3,7 @@ import pytest
from textual.app import App, ComposeResult
from textual.geometry import Offset
from textual.widgets import TextArea
from textual.widgets.text_area import Document, Selection
from textual.widgets.text_area import Selection
TEXT = """I must not fear.
Fear is the mind-killer.
@@ -25,7 +25,7 @@ async def test_mouse_click():
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((2, 2))
assert text_area.selection == Selection.cursor((1, 0))
async def test_mouse_click_clamp_from_right():
@@ -44,7 +44,7 @@ async def test_mouse_click_gutter_clamp():
async with app.run_test() as pilot:
text_area = app.query_one(TextArea)
await pilot.click(TextArea, Offset(x=0, y=3))
assert text_area.selection == Selection.cursor((3, 0))
assert text_area.selection == Selection.cursor((2, 0))
async def test_cursor_movement_basic():
@@ -260,7 +260,10 @@ async def test_cursor_page_down():
text_area.load_text("XXX\n" * 200)
text_area.selection = Selection.cursor((0, 1))
await pilot.press("pagedown")
assert text_area.selection == Selection.cursor((app.console.height - 1, 1))
margin = 2
assert text_area.selection == Selection.cursor(
(app.console.height - 1 - margin, 1)
)
async def test_cursor_page_up():
@@ -271,19 +274,19 @@ async def test_cursor_page_up():
text_area.load_text("XXX\n" * 200)
text_area.selection = Selection.cursor((100, 1))
await pilot.press("pageup")
margin = 2
assert text_area.selection == Selection.cursor(
(100 - app.console.height + 1, 1)
(100 - app.console.height + 1 + margin, 1)
)
@pytest.mark.xfail(reason="still to integrate navigator")
async def test_cursor_vertical_movement_visual_alignment_snapping():
"""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.load_document(Document("こんにちは\n012345"))
text_area.text = "こんにちは\n012345"
text_area.move_cursor((1, 3), record_width=True)
# The '3' is aligned with ん at (0, 1)