2655 lines
98 KiB
Python
2655 lines
98 KiB
Python
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import re
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
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.actions import SkipAction
|
|
from textual.cache import LRUCache
|
|
from textual.color import Color
|
|
from textual.content import Content
|
|
from textual.document._document import (
|
|
Document,
|
|
DocumentBase,
|
|
EditResult,
|
|
Location,
|
|
Selection,
|
|
_utf8_encode,
|
|
)
|
|
from textual.document._document_navigator import DocumentNavigator
|
|
from textual.document._edit import Edit
|
|
from textual.document._history import EditHistory
|
|
from textual.document._syntax_aware_document import (
|
|
SyntaxAwareDocument,
|
|
SyntaxAwareDocumentError,
|
|
)
|
|
from textual.document._wrapped_document import WrappedDocument
|
|
from textual.expand_tabs import expand_tabs_inline, expand_text_tabs_from_widths
|
|
from textual.screen import Screen
|
|
from textual.style import Style as ContentStyle
|
|
|
|
if TYPE_CHECKING:
|
|
from tree_sitter import Language, Query
|
|
|
|
from textual import events, log
|
|
from textual._cells import cell_len, cell_width_to_column_index
|
|
from textual.binding import Binding
|
|
from textual.events import Message, MouseEvent
|
|
from textual.geometry import Offset, Region, Size, Spacing, clamp
|
|
from textual.reactive import Reactive, reactive
|
|
from textual.scroll_view import ScrollView
|
|
from textual.strip import Strip
|
|
|
|
_OPENING_BRACKETS = {"{": "}", "[": "]", "(": ")"}
|
|
_CLOSING_BRACKETS = {v: k for k, v in _OPENING_BRACKETS.items()}
|
|
_TREE_SITTER_PATH = Path(__file__).parent / "../tree-sitter/"
|
|
_HIGHLIGHTS_PATH = _TREE_SITTER_PATH / "highlights/"
|
|
|
|
StartColumn = int
|
|
EndColumn = Optional[int]
|
|
HighlightName = str
|
|
Highlight = Tuple[StartColumn, EndColumn, HighlightName]
|
|
"""A tuple representing a syntax highlight within one line."""
|
|
|
|
BUILTIN_LANGUAGES = [
|
|
"python",
|
|
"markdown",
|
|
"json",
|
|
"toml",
|
|
"yaml",
|
|
"html",
|
|
"css",
|
|
"javascript",
|
|
"rust",
|
|
"go",
|
|
"regex",
|
|
"sql",
|
|
"java",
|
|
"bash",
|
|
"xml",
|
|
]
|
|
"""Languages that are included in the `syntax` extras."""
|
|
|
|
|
|
class ThemeDoesNotExist(Exception):
|
|
"""Raised when the user tries to use a theme which does not exist.
|
|
This means a theme which is not builtin, or has not been registered.
|
|
"""
|
|
|
|
|
|
class LanguageDoesNotExist(Exception):
|
|
"""Raised when the user tries to use a language which does not exist.
|
|
This means a language which is not builtin, or has not been registered.
|
|
"""
|
|
|
|
|
|
@dataclass
|
|
class TextAreaLanguage:
|
|
"""A container for a language which has been registered with the TextArea."""
|
|
|
|
name: str
|
|
"""The name of the language"""
|
|
|
|
language: "Language" | None
|
|
"""The tree-sitter language object if that has been overridden, or None if it is a built-in language."""
|
|
|
|
highlight_query: str
|
|
"""The tree-sitter highlight query to use for syntax highlighting."""
|
|
|
|
|
|
class TextArea(ScrollView):
|
|
DEFAULT_CSS = """\
|
|
TextArea {
|
|
width: 1fr;
|
|
height: 1fr;
|
|
border: tall $border-blurred;
|
|
padding: 0 1;
|
|
color: $foreground;
|
|
background: $surface;
|
|
&.-textual-compact {
|
|
border: none !important;
|
|
}
|
|
& .text-area--cursor {
|
|
text-style: $input-cursor-text-style;
|
|
}
|
|
& .text-area--gutter {
|
|
color: $foreground 40%;
|
|
}
|
|
|
|
& .text-area--cursor-gutter {
|
|
color: $foreground 60%;
|
|
background: $boost;
|
|
text-style: bold;
|
|
}
|
|
|
|
& .text-area--cursor-line {
|
|
background: $boost;
|
|
}
|
|
|
|
& .text-area--selection {
|
|
background: $input-selection-background;
|
|
}
|
|
|
|
& .text-area--matching-bracket {
|
|
background: $foreground 30%;
|
|
}
|
|
|
|
& .text-area--suggestion {
|
|
color: $text-muted;
|
|
}
|
|
|
|
& .text-area--placeholder {
|
|
color: $text 40%;
|
|
}
|
|
|
|
&:focus {
|
|
border: tall $border;
|
|
}
|
|
|
|
&:ansi {
|
|
& .text-area--selection {
|
|
background: transparent;
|
|
text-style: reverse;
|
|
}
|
|
}
|
|
|
|
&:dark {
|
|
.text-area--cursor {
|
|
color: $input-cursor-foreground;
|
|
background: $input-cursor-background;
|
|
}
|
|
&.-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;
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
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",
|
|
"text-area--suggestion",
|
|
"text-area--placeholder",
|
|
}
|
|
"""
|
|
`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. |
|
|
| `text-area--suggestion` | Target the text set in the `suggestion` reactive. |
|
|
| `text-area--placeholder` | Target the placeholder text. |
|
|
"""
|
|
|
|
BINDINGS = [
|
|
# Cursor movement
|
|
Binding("up", "cursor_up", "Cursor up", show=False),
|
|
Binding("down", "cursor_down", "Cursor down", show=False),
|
|
Binding("left", "cursor_left", "Cursor left", show=False),
|
|
Binding("right", "cursor_right", "Cursor right", show=False),
|
|
Binding("ctrl+left", "cursor_word_left", "Cursor word left", show=False),
|
|
Binding("ctrl+right", "cursor_word_right", "Cursor word right", show=False),
|
|
Binding("home,ctrl+a", "cursor_line_start", "Cursor line start", show=False),
|
|
Binding("end,ctrl+e", "cursor_line_end", "Cursor line end", show=False),
|
|
Binding("pageup", "cursor_page_up", "Cursor page up", show=False),
|
|
Binding("pagedown", "cursor_page_down", "Cursor page down", show=False),
|
|
# Making selections (generally holding the shift key and moving cursor)
|
|
Binding(
|
|
"ctrl+shift+left",
|
|
"cursor_word_left(True)",
|
|
"Cursor left word select",
|
|
show=False,
|
|
),
|
|
Binding(
|
|
"ctrl+shift+right",
|
|
"cursor_word_right(True)",
|
|
"Cursor right word select",
|
|
show=False,
|
|
),
|
|
Binding(
|
|
"shift+home",
|
|
"cursor_line_start(True)",
|
|
"Cursor line start select",
|
|
show=False,
|
|
),
|
|
Binding(
|
|
"shift+end", "cursor_line_end(True)", "Cursor line end select", show=False
|
|
),
|
|
Binding("shift+up", "cursor_up(True)", "Cursor up select", show=False),
|
|
Binding("shift+down", "cursor_down(True)", "Cursor down select", show=False),
|
|
Binding("shift+left", "cursor_left(True)", "Cursor left select", show=False),
|
|
Binding("shift+right", "cursor_right(True)", "Cursor right select", show=False),
|
|
# Shortcut ways of making selections
|
|
# Binding("f5", "select_word", "select word", show=False),
|
|
Binding("f6", "select_line", "Select line", show=False),
|
|
Binding("f7", "select_all", "Select all", show=False),
|
|
# Deletion
|
|
Binding("backspace", "delete_left", "Delete character left", show=False),
|
|
Binding(
|
|
"ctrl+w", "delete_word_left", "Delete left to start of word", show=False
|
|
),
|
|
Binding("delete,ctrl+d", "delete_right", "Delete character right", show=False),
|
|
Binding(
|
|
"ctrl+f", "delete_word_right", "Delete right to start of word", show=False
|
|
),
|
|
Binding("ctrl+x", "cut", "Cut", show=False),
|
|
Binding("ctrl+c", "copy", "Copy", show=False),
|
|
Binding("ctrl+v", "paste", "Paste", show=False),
|
|
Binding(
|
|
"ctrl+u", "delete_to_start_of_line", "Delete to line start", show=False
|
|
),
|
|
Binding(
|
|
"ctrl+k",
|
|
"delete_to_end_of_line_or_delete_line",
|
|
"Delete to line end",
|
|
show=False,
|
|
),
|
|
Binding(
|
|
"ctrl+shift+k",
|
|
"delete_line",
|
|
"Delete line",
|
|
show=False,
|
|
),
|
|
Binding("ctrl+z", "undo", "Undo", show=False),
|
|
Binding("ctrl+y", "redo", "Redo", show=False),
|
|
]
|
|
"""
|
|
| Key(s) | Description |
|
|
| :- | :- |
|
|
| up | Move the cursor up. |
|
|
| down | Move the cursor down. |
|
|
| left | Move the cursor left. |
|
|
| ctrl+left | Move the cursor to the start of the word. |
|
|
| ctrl+shift+left | Move the cursor to the start of the word and select. |
|
|
| right | Move the cursor right. |
|
|
| ctrl+right | Move the cursor to the end of the word. |
|
|
| ctrl+shift+right | Move the cursor to the end of the word and select. |
|
|
| home,ctrl+a | Move the cursor to the start of the line. |
|
|
| end,ctrl+e | Move the cursor to the end of the line. |
|
|
| shift+home | Move the cursor to the start of the line and select. |
|
|
| shift+end | Move the cursor to the end of the line and select. |
|
|
| pageup | Move the cursor one page up. |
|
|
| pagedown | Move the cursor one page down. |
|
|
| shift+up | Select while moving the cursor up. |
|
|
| shift+down | Select while moving the cursor down. |
|
|
| shift+left | Select while moving the cursor left. |
|
|
| shift+right | Select while moving the cursor right. |
|
|
| backspace | Delete character to the left of cursor. |
|
|
| ctrl+w | Delete from cursor to start of the word. |
|
|
| delete,ctrl+d | Delete character to the right of cursor. |
|
|
| ctrl+f | Delete from cursor to end of the word. |
|
|
| ctrl+shift+k | Delete the current line. |
|
|
| ctrl+u | Delete from cursor to the start of the line. |
|
|
| ctrl+k | Delete from cursor to the end of the line. |
|
|
| f6 | Select the current line. |
|
|
| f7 | Select all text in the document. |
|
|
| ctrl+z | Undo. |
|
|
| ctrl+y | Redo. |
|
|
| ctrl+x | Cut selection or line if no selection. |
|
|
| ctrl+c | Copy selection to clipboard. |
|
|
| ctrl+v | Paste from clipboard. |
|
|
"""
|
|
|
|
language: Reactive[str | None] = reactive(None, always_update=True, init=False)
|
|
"""The language to use.
|
|
|
|
This must be set to a valid, non-None value for syntax highlighting to work.
|
|
|
|
If the value is a string, a built-in language parser will be used if available.
|
|
|
|
If you wish to use an unsupported language, you'll have to register
|
|
it first using [`TextArea.register_language`][textual.widgets._text_area.TextArea.register_language].
|
|
"""
|
|
|
|
theme: Reactive[str] = reactive("css", always_update=True, init=False)
|
|
"""The name of the theme to use.
|
|
|
|
Themes must be registered using [`TextArea.register_theme`][textual.widgets._text_area.TextArea.register_theme] before they can be used.
|
|
|
|
Syntax highlighting is only possible when the `language` attribute is set.
|
|
"""
|
|
|
|
selection: Reactive[Selection] = reactive(
|
|
Selection(), init=False, always_update=True
|
|
)
|
|
"""The selection start and end locations (zero-based line_index, offset).
|
|
|
|
This represents the cursor location and the current selection.
|
|
|
|
The `Selection.end` always refers to the cursor location.
|
|
|
|
If no text is selected, then `Selection.end == Selection.start` is True.
|
|
|
|
The text selected in the document is available via the `TextArea.selected_text` property.
|
|
"""
|
|
|
|
show_line_numbers: Reactive[bool] = reactive(False, init=False)
|
|
"""True to show the line number column on the left edge, otherwise False.
|
|
|
|
Changing this value will immediately re-render the `TextArea`."""
|
|
|
|
line_number_start: Reactive[int] = reactive(1, init=False)
|
|
"""The line number the first line should be."""
|
|
|
|
indent_width: Reactive[int] = reactive(4, init=False)
|
|
"""The width of tabs or the multiple of spaces to align to on pressing the `tab` key.
|
|
|
|
If the document currently open contains tabs that are currently visible on screen,
|
|
altering this value will immediately change the display width of the visible tabs.
|
|
"""
|
|
|
|
match_cursor_bracket: Reactive[bool] = reactive(True, init=False)
|
|
"""If the cursor is at a bracket, highlight the matching bracket (if found)."""
|
|
|
|
cursor_blink: Reactive[bool] = reactive(True, init=False)
|
|
"""True if the cursor should blink."""
|
|
|
|
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.
|
|
"""
|
|
|
|
show_cursor: Reactive[bool] = reactive(True)
|
|
"""Show the cursor in read only mode?
|
|
|
|
If `True`, the cursor will be visible when `read_only==True`.
|
|
If `False`, the cursor will be hidden when `read_only==True`, and the TextArea will
|
|
scroll like other containers.
|
|
|
|
"""
|
|
|
|
compact: reactive[bool] = reactive(False, toggle_class="-textual-compact")
|
|
"""Enable compact display?"""
|
|
|
|
highlight_cursor_line: reactive[bool] = reactive(True)
|
|
"""Highlight the line under the cursor?"""
|
|
|
|
_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."""
|
|
|
|
suggestion: Reactive[str] = reactive("")
|
|
"""A suggestion for auto-complete (pressing right will insert it)."""
|
|
|
|
hide_suggestion_on_blur: Reactive[bool] = reactive(True)
|
|
"""Hide suggestion when the TextArea does not have focus."""
|
|
|
|
placeholder: Reactive[str | Content] = reactive("")
|
|
"""Text to show when the text area has no content."""
|
|
|
|
@dataclass
|
|
class Changed(Message):
|
|
"""Posted when the content inside the TextArea changes.
|
|
|
|
Handle this message using the `on` decorator - `@on(TextArea.Changed)`
|
|
or a method named `on_text_area_changed`.
|
|
"""
|
|
|
|
text_area: TextArea
|
|
"""The `text_area` that sent this message."""
|
|
|
|
@property
|
|
def control(self) -> TextArea:
|
|
"""The `TextArea` that sent this message."""
|
|
return self.text_area
|
|
|
|
@dataclass
|
|
class SelectionChanged(Message):
|
|
"""Posted when the selection changes.
|
|
|
|
This includes when the cursor moves or when text is selected."""
|
|
|
|
selection: Selection
|
|
"""The new selection."""
|
|
text_area: TextArea
|
|
"""The `text_area` that sent this message."""
|
|
|
|
@property
|
|
def control(self) -> TextArea:
|
|
return self.text_area
|
|
|
|
def __init__(
|
|
self,
|
|
text: str = "",
|
|
*,
|
|
language: str | None = None,
|
|
theme: str = "css",
|
|
soft_wrap: bool = True,
|
|
tab_behavior: Literal["focus", "indent"] = "focus",
|
|
read_only: bool = False,
|
|
show_cursor: bool = True,
|
|
show_line_numbers: bool = False,
|
|
line_number_start: int = 1,
|
|
max_checkpoints: int = 50,
|
|
name: str | None = None,
|
|
id: str | None = None,
|
|
classes: str | None = None,
|
|
disabled: bool = False,
|
|
tooltip: RenderableType | None = None,
|
|
compact: bool = False,
|
|
highlight_cursor_line: bool = True,
|
|
placeholder: str | Content = "",
|
|
) -> None:
|
|
"""Construct a new `TextArea`.
|
|
|
|
Args:
|
|
text: The initial text to load into the TextArea.
|
|
language: The language to use.
|
|
theme: The theme to use.
|
|
soft_wrap: Enable soft wrapping.
|
|
tab_behavior: 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.
|
|
show_cursor: Show the cursor in read only mode (no effect otherwise).
|
|
show_line_numbers: Show line numbers on the left edge.
|
|
line_number_start: What line number to start on.
|
|
max_checkpoints: The maximum number of undo history checkpoints to retain.
|
|
name: The name of the `TextArea` widget.
|
|
id: The ID of the widget, used to refer to it from Textual CSS.
|
|
classes: One or more Textual CSS compatible class names separated by spaces.
|
|
disabled: True if the widget is disabled.
|
|
tooltip: Optional tooltip.
|
|
compact: Enable compact style (without borders).
|
|
highlight_cursor_line: Highlight the line under the cursor.
|
|
placeholder: Text to display when there is not content.
|
|
"""
|
|
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
|
|
|
self._languages: dict[str, TextAreaLanguage] = {}
|
|
"""Maps language names to TextAreaLanguage. This is only used for languages
|
|
registered by end-users using `TextArea.register_language`. If a user attempts
|
|
to set `TextArea.language` to a language that is not registered here, we'll
|
|
attempt to get it from the environment. If that fails, we'll fall back to
|
|
plain text.
|
|
"""
|
|
|
|
self._themes: dict[str, TextAreaTheme] = {}
|
|
"""Maps theme names to TextAreaTheme."""
|
|
|
|
self.indent_type: Literal["tabs", "spaces"] = "spaces"
|
|
"""Whether to indent using tabs or spaces."""
|
|
|
|
self._word_pattern = re.compile(r"(?<=\W)(?=\w)|(?<=\w)(?=\W)")
|
|
"""Compiled regular expression for what we consider to be a 'word'."""
|
|
|
|
self.history: EditHistory = EditHistory(
|
|
max_checkpoints=max_checkpoints,
|
|
checkpoint_timer=2.0,
|
|
checkpoint_max_characters=100,
|
|
)
|
|
"""A stack (the end of the list is the top of the stack) for tracking edits."""
|
|
|
|
self._selecting = False
|
|
"""True if we're currently selecting text using the mouse, otherwise False."""
|
|
|
|
self._matching_bracket_location: Location | None = None
|
|
"""The location (row, column) of the bracket which matches the bracket the
|
|
cursor is currently at. If the cursor is at a bracket, or there's no matching
|
|
bracket, this will be `None`."""
|
|
|
|
self._highlights: dict[int, list[Highlight]] = defaultdict(list)
|
|
"""Mapping line numbers to the set of highlights for that line."""
|
|
|
|
self._highlight_query: "Query | None" = None
|
|
"""The query that's currently being used for highlighting."""
|
|
|
|
self.document: DocumentBase = Document(text)
|
|
"""The document this widget is currently editing."""
|
|
|
|
self.wrapped_document: WrappedDocument = WrappedDocument(self.document)
|
|
"""The wrapped view of the document."""
|
|
|
|
self.navigator: DocumentNavigator = DocumentNavigator(self.wrapped_document)
|
|
"""Queried to determine where the cursor should move given a navigation
|
|
action, accounting for wrapping etc."""
|
|
|
|
self._cursor_offset = (0, 0)
|
|
"""The virtual offset of the cursor (not screen-space offset)."""
|
|
|
|
self.set_reactive(TextArea.soft_wrap, soft_wrap)
|
|
self.set_reactive(TextArea.read_only, read_only)
|
|
self.set_reactive(TextArea.show_cursor, show_cursor)
|
|
self.set_reactive(TextArea.show_line_numbers, show_line_numbers)
|
|
self.set_reactive(TextArea.line_number_start, line_number_start)
|
|
self.set_reactive(TextArea.highlight_cursor_line, highlight_cursor_line)
|
|
self.set_reactive(TextArea.placeholder, placeholder)
|
|
|
|
self._line_cache: LRUCache[tuple, Strip] = LRUCache(1024)
|
|
|
|
self._set_document(text, language)
|
|
|
|
self.language = language
|
|
self.theme = theme
|
|
|
|
self._theme: TextAreaTheme
|
|
"""The `TextAreaTheme` corresponding to the set theme name. When the `theme`
|
|
reactive is set as a string, the watcher will update this attribute to the
|
|
corresponding `TextAreaTheme` object."""
|
|
|
|
self.tab_behavior = tab_behavior
|
|
|
|
if tooltip is not None:
|
|
self.tooltip = tooltip
|
|
|
|
self.compact = compact
|
|
|
|
@classmethod
|
|
def code_editor(
|
|
cls,
|
|
text: str = "",
|
|
*,
|
|
language: str | None = None,
|
|
theme: str = "monokai",
|
|
soft_wrap: bool = False,
|
|
tab_behavior: Literal["focus", "indent"] = "indent",
|
|
read_only: bool = False,
|
|
show_cursor: bool = True,
|
|
show_line_numbers: bool = True,
|
|
line_number_start: int = 1,
|
|
max_checkpoints: int = 50,
|
|
name: str | None = None,
|
|
id: str | None = None,
|
|
classes: str | None = None,
|
|
disabled: bool = False,
|
|
tooltip: RenderableType | None = None,
|
|
compact: bool = False,
|
|
highlight_cursor_line: bool = True,
|
|
placeholder: str | Content = "",
|
|
) -> TextArea:
|
|
"""Construct a new `TextArea` with sensible defaults for editing code.
|
|
|
|
This instantiates a `TextArea` with line numbers enabled, soft wrapping
|
|
disabled, "indent" tab behavior, and the "monokai" theme.
|
|
|
|
Args:
|
|
text: The initial text to load into the TextArea.
|
|
language: The language to use.
|
|
theme: The theme to use.
|
|
soft_wrap: Enable soft wrapping.
|
|
tab_behavior: 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.
|
|
show_cursor: Show the cursor in read only mode (no effect otherwise).
|
|
show_line_numbers: Show line numbers on the left edge.
|
|
line_number_start: What line number to start on.
|
|
name: The name of the `TextArea` widget.
|
|
id: The ID of the widget, used to refer to it from Textual CSS.
|
|
classes: One or more Textual CSS compatible class names separated by spaces.
|
|
disabled: True if the widget is disabled.
|
|
tooltip: Optional tooltip
|
|
compact: Enable compact style (without borders).
|
|
highlight_cursor_line: Highlight the line under the cursor.
|
|
"""
|
|
return cls(
|
|
text,
|
|
language=language,
|
|
theme=theme,
|
|
soft_wrap=soft_wrap,
|
|
tab_behavior=tab_behavior,
|
|
read_only=read_only,
|
|
show_cursor=show_cursor,
|
|
show_line_numbers=show_line_numbers,
|
|
line_number_start=line_number_start,
|
|
max_checkpoints=max_checkpoints,
|
|
name=name,
|
|
id=id,
|
|
classes=classes,
|
|
disabled=disabled,
|
|
tooltip=tooltip,
|
|
compact=compact,
|
|
highlight_cursor_line=highlight_cursor_line,
|
|
placeholder=placeholder,
|
|
)
|
|
|
|
@staticmethod
|
|
def _get_builtin_highlight_query(language_name: str) -> str:
|
|
"""Get the highlight query for a builtin language.
|
|
|
|
Args:
|
|
language_name: The name of the builtin language.
|
|
|
|
Returns:
|
|
The highlight query.
|
|
"""
|
|
try:
|
|
highlight_query_path = (
|
|
Path(_HIGHLIGHTS_PATH.resolve()) / f"{language_name}.scm"
|
|
)
|
|
highlight_query = highlight_query_path.read_text()
|
|
except OSError as error:
|
|
log.warning(f"Unable to load highlight query. {error}")
|
|
highlight_query = ""
|
|
|
|
return highlight_query
|
|
|
|
def notify_style_update(self) -> None:
|
|
self._line_cache.clear()
|
|
super().notify_style_update()
|
|
|
|
def update_suggestion(self) -> None:
|
|
"""A hook to update the [`suggestion`][textual.widgets.TextArea.suggestion] attribute."""
|
|
|
|
def check_consume_key(self, key: str, character: str | None = None) -> bool:
|
|
"""Check if the widget may consume the given key.
|
|
|
|
As a textarea we are expecting to capture printable keys.
|
|
|
|
Args:
|
|
key: A key identifier.
|
|
character: A character associated with the key, or `None` if there isn't one.
|
|
|
|
Returns:
|
|
`True` if the widget may capture the key in its `Key` message, or `False` if it won't.
|
|
"""
|
|
if self.read_only:
|
|
# In read only mode we don't consume any key events
|
|
return False
|
|
if self.tab_behavior == "indent" and key == "tab":
|
|
# If tab_behavior is indent, then we consume the tab
|
|
return True
|
|
# Otherwise we capture all printable keys
|
|
return character is not None and character.isprintable()
|
|
|
|
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:
|
|
return
|
|
|
|
captures = self.document.query_syntax_tree(self._highlight_query)
|
|
for highlight_name, nodes in captures.items():
|
|
for node in nodes:
|
|
node_start_row, node_start_column = node.start_point
|
|
node_end_row, node_end_column = node.end_point
|
|
|
|
if node_start_row == node_end_row:
|
|
highlight = (node_start_column, node_end_column, highlight_name)
|
|
highlights[node_start_row].append(highlight)
|
|
else:
|
|
# Add the first line of the node range
|
|
highlights[node_start_row].append(
|
|
(node_start_column, None, highlight_name)
|
|
)
|
|
|
|
# Add the middle lines - entire row of this node is highlighted
|
|
for node_row in range(node_start_row + 1, node_end_row):
|
|
highlights[node_row].append((0, None, highlight_name))
|
|
|
|
# Add the last line of the node range
|
|
highlights[node_end_row].append(
|
|
(0, node_end_column, highlight_name)
|
|
)
|
|
|
|
def _watch_has_focus(self, focus: bool) -> None:
|
|
self._cursor_visible = focus
|
|
if focus:
|
|
self._restart_blink()
|
|
self.app.cursor_position = self.cursor_screen_offset
|
|
self.history.checkpoint()
|
|
else:
|
|
self._pause_blink(visible=False)
|
|
|
|
def _watch_selection(
|
|
self, previous_selection: Selection, selection: Selection
|
|
) -> None:
|
|
"""When the cursor moves, scroll it into view."""
|
|
# Find the visual offset of the cursor in the document
|
|
|
|
if not self.is_mounted:
|
|
return
|
|
|
|
self.app.clear_selection()
|
|
|
|
cursor_location = selection.end
|
|
|
|
self.scroll_cursor_visible()
|
|
|
|
cursor_row, cursor_column = cursor_location
|
|
|
|
try:
|
|
character = self.document[cursor_row][cursor_column]
|
|
except IndexError:
|
|
character = ""
|
|
|
|
# Record the location of a matching closing/opening bracket.
|
|
match_location = self.find_matching_bracket(character, cursor_location)
|
|
self._matching_bracket_location = match_location
|
|
if match_location is not None:
|
|
_, offset_y = self._cursor_offset
|
|
self.refresh_lines(offset_y)
|
|
|
|
self.app.cursor_position = self.cursor_screen_offset
|
|
if previous_selection != selection:
|
|
self.post_message(self.SelectionChanged(selection, self))
|
|
|
|
def _watch_cursor_blink(self, blink: bool) -> None:
|
|
if not self.is_mounted:
|
|
return None
|
|
if blink and self.has_focus:
|
|
self._restart_blink()
|
|
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(
|
|
self.cursor_location
|
|
)
|
|
|
|
def find_matching_bracket(
|
|
self, bracket: str, search_from: Location
|
|
) -> Location | None:
|
|
"""If the character is a bracket, find the matching bracket.
|
|
|
|
Args:
|
|
bracket: The character we're searching for the matching bracket of.
|
|
search_from: The location to start the search.
|
|
|
|
Returns:
|
|
The `Location` of the matching bracket, or `None` if it's not found.
|
|
If the character is not available for bracket matching, `None` is returned.
|
|
"""
|
|
match_location = None
|
|
bracket_stack: list[str] = []
|
|
if bracket in _OPENING_BRACKETS:
|
|
# Search forwards for a closing bracket
|
|
for candidate, candidate_location in self._yield_character_locations(
|
|
search_from
|
|
):
|
|
if candidate in _OPENING_BRACKETS:
|
|
bracket_stack.append(candidate)
|
|
elif candidate in _CLOSING_BRACKETS:
|
|
if (
|
|
bracket_stack
|
|
and bracket_stack[-1] == _CLOSING_BRACKETS[candidate]
|
|
):
|
|
bracket_stack.pop()
|
|
if not bracket_stack:
|
|
match_location = candidate_location
|
|
break
|
|
elif bracket in _CLOSING_BRACKETS:
|
|
# Search backwards for an opening bracket
|
|
for (
|
|
candidate,
|
|
candidate_location,
|
|
) in self._yield_character_locations_reverse(search_from):
|
|
if candidate in _CLOSING_BRACKETS:
|
|
bracket_stack.append(candidate)
|
|
elif candidate in _OPENING_BRACKETS:
|
|
if (
|
|
bracket_stack
|
|
and bracket_stack[-1] == _OPENING_BRACKETS[candidate]
|
|
):
|
|
bracket_stack.pop()
|
|
if not bracket_stack:
|
|
match_location = candidate_location
|
|
break
|
|
|
|
return match_location
|
|
|
|
def _validate_selection(self, selection: Selection) -> Selection:
|
|
"""Clamp the selection to valid locations."""
|
|
start, end = selection
|
|
clamp_visitable = self.clamp_visitable
|
|
return Selection(clamp_visitable(start), clamp_visitable(end))
|
|
|
|
def _watch_language(self, language: str | None) -> None:
|
|
"""When the language is updated, update the type of document."""
|
|
self._set_document(self.document.text, language)
|
|
|
|
def _watch_show_line_numbers(self) -> None:
|
|
"""The line number gutter contributes to virtual size, so recalculate."""
|
|
self._rewrap_and_refresh_virtual_size()
|
|
self.scroll_cursor_visible()
|
|
|
|
def _watch_line_number_start(self) -> None:
|
|
"""The line number gutter max size might change and contributes to virtual size, so recalculate."""
|
|
self._rewrap_and_refresh_virtual_size()
|
|
self.scroll_cursor_visible()
|
|
|
|
def _watch_indent_width(self) -> None:
|
|
"""Changing width of tabs will change the document display width."""
|
|
self._rewrap_and_refresh_virtual_size()
|
|
self.scroll_cursor_visible()
|
|
|
|
def _watch_show_vertical_scrollbar(self) -> None:
|
|
if self.wrap_width:
|
|
self._rewrap_and_refresh_virtual_size()
|
|
self.scroll_cursor_visible()
|
|
|
|
def _watch_theme(self, theme: str) -> None:
|
|
"""We set the styles on this widget when the theme changes, to ensure that
|
|
if padding is applied, the colors match."""
|
|
self._set_theme(theme)
|
|
|
|
def _app_theme_changed(self) -> None:
|
|
self._set_theme(self._theme.name)
|
|
|
|
def _set_theme(self, theme: str) -> None:
|
|
theme_object: TextAreaTheme | None
|
|
|
|
# If the user supplied a string theme name, find it and apply it.
|
|
try:
|
|
theme_object = self._themes[theme]
|
|
except KeyError:
|
|
theme_object = TextAreaTheme.get_builtin_theme(theme)
|
|
if theme_object is None:
|
|
raise ThemeDoesNotExist(
|
|
f"{theme!r} is not a builtin theme, or it has not been registered. "
|
|
f"To use a custom theme, register it first using `register_theme`, "
|
|
f"then switch to that theme by setting the `TextArea.theme` attribute."
|
|
) from None
|
|
|
|
self._theme = dataclasses.replace(theme_object)
|
|
if theme_object:
|
|
base_style = theme_object.base_style
|
|
if base_style:
|
|
color = base_style.color
|
|
background = base_style.bgcolor
|
|
if color:
|
|
self.styles.color = Color.from_rich_color(color)
|
|
if background:
|
|
self.styles.background = Color.from_rich_color(background)
|
|
else:
|
|
# When the theme doesn't define a base style (e.g. the `css` theme),
|
|
# the TextArea background/color should fallback to its CSS colors.
|
|
#
|
|
# Since these styles may have already been changed by another theme,
|
|
# we need to reset the background/color styles to the default values.
|
|
self.styles.color = None
|
|
self.styles.background = None
|
|
|
|
@property
|
|
def available_themes(self) -> set[str]:
|
|
"""A list of the names of the themes available to the `TextArea`.
|
|
|
|
The values in this list can be assigned `theme` reactive attribute of
|
|
`TextArea`.
|
|
|
|
You can retrieve the full specification for a theme by passing one of
|
|
the strings from this list into `TextAreaTheme.get_by_name(theme_name: str)`.
|
|
|
|
Alternatively, you can directly retrieve a list of `TextAreaTheme` objects
|
|
(which contain the full theme specification) by calling
|
|
`TextAreaTheme.builtin_themes()`.
|
|
"""
|
|
return {
|
|
theme.name for theme in TextAreaTheme.builtin_themes()
|
|
} | self._themes.keys()
|
|
|
|
def register_theme(self, theme: TextAreaTheme) -> None:
|
|
"""Register a theme for use by the `TextArea`.
|
|
|
|
After registering a theme, you can set themes by assigning the theme
|
|
name to the `TextArea.theme` reactive attribute. For example
|
|
`text_area.theme = "my_custom_theme"` where `"my_custom_theme"` is the
|
|
name of the theme you registered.
|
|
|
|
If you supply a theme with a name that already exists that theme
|
|
will be overwritten.
|
|
"""
|
|
self._themes[theme.name] = theme
|
|
|
|
@property
|
|
def available_languages(self) -> set[str]:
|
|
"""A set of the names of languages available to the `TextArea`.
|
|
|
|
The values in this set can be assigned to the `language` reactive attribute
|
|
of `TextArea`.
|
|
|
|
The returned set contains the builtin languages installed with the syntax extras,
|
|
plus those registered via the `register_language` method.
|
|
"""
|
|
return set(BUILTIN_LANGUAGES) | self._languages.keys()
|
|
|
|
def register_language(
|
|
self,
|
|
name: str,
|
|
language: "Language",
|
|
highlight_query: str,
|
|
) -> None:
|
|
"""Register a language and corresponding highlight query.
|
|
|
|
Calling this method does not change the language of the `TextArea`.
|
|
On switching to this language (via the `language` reactive attribute),
|
|
syntax highlighting will be performed using the given highlight query.
|
|
|
|
If a string `name` is supplied for a builtin supported language, then
|
|
this method will update the default highlight query for that language.
|
|
|
|
Registering a language only registers it to this instance of `TextArea`.
|
|
|
|
Args:
|
|
name: The name of the language.
|
|
language: A tree-sitter `Language` object.
|
|
highlight_query: The highlight query to use for syntax highlighting this language.
|
|
"""
|
|
self._languages[name] = TextAreaLanguage(name, language, highlight_query)
|
|
|
|
def update_highlight_query(self, name: str, highlight_query: str) -> None:
|
|
"""Update the highlight query for an already registered language.
|
|
|
|
Args:
|
|
name: The name of the language.
|
|
highlight_query: The highlight query to use for syntax highlighting this language.
|
|
"""
|
|
if name not in self._languages:
|
|
self._languages[name] = TextAreaLanguage(name, None, highlight_query)
|
|
else:
|
|
self._languages[name].highlight_query = highlight_query
|
|
|
|
# If this is the currently loaded language, reload the document because
|
|
# it could be a different highlight query for the same language.
|
|
if name == self.language:
|
|
self._set_document(self.text, name)
|
|
|
|
def _set_document(self, text: str, language: str | None) -> None:
|
|
"""Construct and return an appropriate document.
|
|
|
|
Args:
|
|
text: The text of the document.
|
|
language: The name of the language to use. This must correspond to a tree-sitter
|
|
language available in the current environment (e.g. use `python` for `tree-sitter-python`).
|
|
If None, the document will be treated as plain text.
|
|
"""
|
|
self._highlight_query = None
|
|
if TREE_SITTER and language:
|
|
if language in self._languages:
|
|
# User-registered languages take priority.
|
|
highlight_query = self._languages[language].highlight_query
|
|
document_language = self._languages[language].language
|
|
if document_language is None:
|
|
document_language = get_language(language)
|
|
else:
|
|
# No user-registered language, so attempt to use a built-in language.
|
|
highlight_query = self._get_builtin_highlight_query(language)
|
|
document_language = get_language(language)
|
|
|
|
# No built-in language, and no user-registered language: use plain text and warn.
|
|
if document_language is None:
|
|
raise LanguageDoesNotExist(
|
|
f"tree-sitter is available, but no built-in or user-registered language called {language!r}.\n"
|
|
f"Ensure the language is installed (e.g. `pip install tree-sitter-ruby`)\n"
|
|
f"Falling back to plain text."
|
|
)
|
|
else:
|
|
document: DocumentBase
|
|
try:
|
|
document = SyntaxAwareDocument(text, document_language)
|
|
except SyntaxAwareDocumentError:
|
|
document = Document(text)
|
|
log.warning(
|
|
f"Parser not found for language {document_language!r}. Parsing disabled."
|
|
)
|
|
else:
|
|
self._highlight_query = document.prepare_query(highlight_query)
|
|
elif language and not TREE_SITTER:
|
|
# User has supplied a language i.e. `TextArea(language="python")`, but they
|
|
# don't have tree-sitter available in the environment. We fallback to plain text.
|
|
log.warning(
|
|
"tree-sitter not available in this environment. Parsing disabled.\n"
|
|
"You may need to install the `syntax` extras alongside textual.\n"
|
|
"Try `pip install 'textual[syntax]'` or '`poetry add textual[syntax]' to get started quickly.\n\n"
|
|
"Alternatively, install tree-sitter manually (`pip install tree-sitter`) and then\n"
|
|
"install the required language (e.g. `pip install tree-sitter-ruby`), then register it.\n"
|
|
"and its highlight query using TextArea.register_language().\n\n"
|
|
"Falling back to plain text for now."
|
|
)
|
|
document = Document(text)
|
|
else:
|
|
# tree-sitter is available, but the user has supplied None or "" for the language.
|
|
# Use a regular plain-text document.
|
|
document = Document(text)
|
|
|
|
self.document = document
|
|
self.wrapped_document = WrappedDocument(document, tab_width=self.indent_width)
|
|
self.navigator = DocumentNavigator(self.wrapped_document)
|
|
self._build_highlight_map()
|
|
self.move_cursor((0, 0))
|
|
self._rewrap_and_refresh_virtual_size()
|
|
|
|
@property
|
|
def _visible_line_indices(self) -> tuple[int, int]:
|
|
"""Return the visible line indices as a tuple (top, bottom).
|
|
|
|
Returns:
|
|
A tuple (top, bottom) indicating the top and bottom visible line indices.
|
|
"""
|
|
_, scroll_offset_y = self.scroll_offset
|
|
return scroll_offset_y, scroll_offset_y + self.size.height
|
|
|
|
def _watch_scroll_x(self) -> None:
|
|
self.app.cursor_position = self.cursor_screen_offset
|
|
|
|
def _watch_scroll_y(self) -> None:
|
|
self.app.cursor_position = self.cursor_screen_offset
|
|
|
|
def load_text(self, text: str) -> None:
|
|
"""Load text into the TextArea.
|
|
|
|
This will replace the text currently in the TextArea and clear the edit history.
|
|
|
|
Args:
|
|
text: The text to load into the TextArea.
|
|
"""
|
|
self.history.clear()
|
|
self._set_document(text, self.language)
|
|
self.post_message(self.Changed(self).set_sender(self))
|
|
self.update_suggestion()
|
|
|
|
def _on_resize(self) -> None:
|
|
self._rewrap_and_refresh_virtual_size()
|
|
|
|
def _watch_soft_wrap(self) -> None:
|
|
self._rewrap_and_refresh_virtual_size()
|
|
self.call_after_refresh(self.scroll_cursor_visible, center=True)
|
|
|
|
@property
|
|
def wrap_width(self) -> int:
|
|
"""The width which gets used when the document wraps.
|
|
|
|
Accounts for gutter, scrollbars, etc.
|
|
"""
|
|
width, _ = self.scrollable_content_region.size
|
|
cursor_width = 1
|
|
if self.soft_wrap:
|
|
return max(0, width - self.gutter_width - cursor_width)
|
|
return 0
|
|
|
|
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
|
|
def is_syntax_aware(self) -> bool:
|
|
"""True if the TextArea is currently syntax aware - i.e. it's parsing document content."""
|
|
return isinstance(self.document, SyntaxAwareDocument)
|
|
|
|
def _yield_character_locations(
|
|
self, start: Location
|
|
) -> Iterable[tuple[str, Location]]:
|
|
"""Yields character locations starting from the given location.
|
|
|
|
Does not yield location of line separator characters like `\\n`.
|
|
|
|
Args:
|
|
start: The location to start yielding from.
|
|
|
|
Returns:
|
|
Yields tuples of (character, (row, column)).
|
|
"""
|
|
row, column = start
|
|
document = self.document
|
|
line_count = document.line_count
|
|
|
|
while 0 <= row < line_count:
|
|
line = document[row]
|
|
while column < len(line):
|
|
yield line[column], (row, column)
|
|
column += 1
|
|
column = 0
|
|
row += 1
|
|
|
|
def _yield_character_locations_reverse(
|
|
self, start: Location
|
|
) -> Iterable[tuple[str, Location]]:
|
|
row, column = start
|
|
document = self.document
|
|
line_count = document.line_count
|
|
|
|
while line_count > row >= 0:
|
|
line = document[row]
|
|
if column == -1:
|
|
column = len(line) - 1
|
|
while column >= 0:
|
|
yield line[column], (row, column)
|
|
column -= 1
|
|
row -= 1
|
|
|
|
def _refresh_size(self) -> None:
|
|
"""Update the virtual size of the TextArea."""
|
|
if self.soft_wrap:
|
|
self.virtual_size = Size(0, self.wrapped_document.height)
|
|
else:
|
|
# +1 width to make space for the cursor resting at the end of the line
|
|
width, height = self.document.get_size(self.indent_width)
|
|
self.virtual_size = Size(width + self.gutter_width + 1, height)
|
|
self._refresh_scrollbars()
|
|
|
|
@property
|
|
def _draw_cursor(self) -> bool:
|
|
"""Draw the cursor?"""
|
|
if self.read_only:
|
|
# If we are in read only mode, we don't want the cursor to blink
|
|
return self.show_cursor and self.has_focus
|
|
draw_cursor = (
|
|
self.has_focus
|
|
and not self.cursor_blink
|
|
or (self.cursor_blink and self._cursor_visible)
|
|
)
|
|
return draw_cursor
|
|
|
|
@property
|
|
def _has_cursor(self) -> bool:
|
|
"""Is there a usable cursor?"""
|
|
return not (self.read_only and not self.show_cursor)
|
|
|
|
def get_line(self, line_index: int) -> Text:
|
|
"""Retrieve the line at the given line index.
|
|
|
|
You can stylize the Text object returned here to apply additional
|
|
styling to TextArea content.
|
|
|
|
Args:
|
|
line_index: The index of the line.
|
|
|
|
Returns:
|
|
A `rich.Text` object containing the requested line.
|
|
"""
|
|
line_string = self.document.get_line(line_index)
|
|
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.
|
|
"""
|
|
|
|
if not self.text and self.placeholder:
|
|
placeholder_lines = Content.from_text(self.placeholder).wrap(
|
|
self.content_size.width
|
|
)
|
|
if y < len(placeholder_lines):
|
|
style = self.get_visual_style("text-area--placeholder")
|
|
content = placeholder_lines[y].stylize(style)
|
|
if self._draw_cursor and y == 0:
|
|
theme = self._theme
|
|
cursor_style = theme.cursor_style if theme else None
|
|
if cursor_style:
|
|
content = content.stylize(
|
|
ContentStyle.from_rich_style(cursor_style), 0, 1
|
|
)
|
|
return Strip(
|
|
content.render_segments(self.visual_style), content.cell_length
|
|
)
|
|
|
|
scroll_x, scroll_y = self.scroll_offset
|
|
absolute_y = scroll_y + y
|
|
selection = self.selection
|
|
_, cursor_y = self._cursor_offset
|
|
cache_key = (
|
|
self.size,
|
|
scroll_x,
|
|
absolute_y,
|
|
(
|
|
selection
|
|
if selection.contains_line(absolute_y) or self.soft_wrap
|
|
else selection.end[0] == absolute_y
|
|
),
|
|
(
|
|
selection.end
|
|
if (
|
|
self._cursor_visible
|
|
and self.cursor_blink
|
|
and absolute_y == cursor_y
|
|
)
|
|
else None
|
|
),
|
|
self.theme,
|
|
self._matching_bracket_location,
|
|
self.match_cursor_bracket,
|
|
self.soft_wrap,
|
|
self.show_line_numbers,
|
|
self.read_only,
|
|
self.show_cursor,
|
|
self.suggestion,
|
|
)
|
|
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
|
|
base_style = (
|
|
theme.base_style
|
|
if theme and theme.base_style is not None
|
|
else self.rich_style
|
|
)
|
|
|
|
wrapped_document = self.wrapped_document
|
|
scroll_x, scroll_y = self.scroll_offset
|
|
|
|
# Account for how much the TextArea is scrolled.
|
|
y_offset = y + scroll_y
|
|
|
|
# If we're beyond the height of the document, render blank lines
|
|
out_of_bounds = y_offset >= wrapped_document.height
|
|
|
|
if out_of_bounds:
|
|
return Strip.blank(self.size.width, base_style)
|
|
|
|
# Get the line corresponding to this offset
|
|
try:
|
|
line_info = wrapped_document._offset_to_line_info[y_offset]
|
|
except IndexError:
|
|
line_info = None
|
|
|
|
if line_info is None:
|
|
return Strip.blank(self.size.width, base_style)
|
|
|
|
line_index, section_offset = line_info
|
|
|
|
line = self.get_line(line_index)
|
|
line_character_count = len(line)
|
|
line.tab_size = self.indent_width
|
|
line.set_length(line_character_count + 1) # space at end for cursor
|
|
virtual_width, _virtual_height = self.virtual_size
|
|
|
|
selection = self.selection
|
|
start, end = selection
|
|
cursor_row, cursor_column = end
|
|
|
|
selection_top, selection_bottom = sorted(selection)
|
|
selection_top_row, selection_top_column = selection_top
|
|
selection_bottom_row, selection_bottom_column = selection_bottom
|
|
|
|
highlight_cursor_line = self.highlight_cursor_line and self._has_cursor
|
|
cursor_line_style = (
|
|
theme.cursor_line_style if (theme and highlight_cursor_line) else None
|
|
)
|
|
has_cursor = self._has_cursor
|
|
|
|
if has_cursor and cursor_line_style and cursor_row == line_index:
|
|
line.stylize(cursor_line_style)
|
|
|
|
# Selection styling
|
|
if start != end and selection_top_row <= line_index <= selection_bottom_row:
|
|
# If this row intersects with the selection range
|
|
selection_style = theme.selection_style if theme else None
|
|
cursor_row, _ = end
|
|
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.plain = "▌"
|
|
line.stylize(Style(color=selection_style.bgcolor))
|
|
else:
|
|
if line_index == selection_top_row == selection_bottom_row:
|
|
# Selection within a single line
|
|
line.stylize(
|
|
selection_style,
|
|
start=selection_top_column,
|
|
end=selection_bottom_column,
|
|
)
|
|
else:
|
|
# Selection spanning multiple lines
|
|
if line_index == selection_top_row:
|
|
line.stylize(
|
|
selection_style,
|
|
start=selection_top_column,
|
|
end=line_character_count,
|
|
)
|
|
elif line_index == selection_bottom_row:
|
|
line.stylize(selection_style, end=selection_bottom_column)
|
|
else:
|
|
line.stylize(selection_style, end=line_character_count)
|
|
|
|
highlights = self._highlights
|
|
if highlights and theme:
|
|
line_bytes = _utf8_encode(line.plain)
|
|
byte_to_codepoint = build_byte_to_codepoint_dict(line_bytes)
|
|
get_highlight_from_theme = theme.syntax_styles.get
|
|
line_highlights = highlights[line_index]
|
|
for highlight_start, highlight_end, highlight_name in line_highlights:
|
|
node_style = get_highlight_from_theme(highlight_name)
|
|
if node_style is not None:
|
|
line.stylize(
|
|
node_style,
|
|
byte_to_codepoint.get(highlight_start, 0),
|
|
byte_to_codepoint.get(highlight_end) if highlight_end else None,
|
|
)
|
|
|
|
# Highlight the cursor
|
|
matching_bracket = self._matching_bracket_location
|
|
match_cursor_bracket = self.match_cursor_bracket
|
|
draw_matched_brackets = (
|
|
has_cursor
|
|
and match_cursor_bracket
|
|
and matching_bracket is not None
|
|
and start == end
|
|
)
|
|
|
|
if cursor_row == line_index:
|
|
draw_cursor = self._draw_cursor
|
|
if draw_matched_brackets:
|
|
matching_bracket_style = theme.bracket_matching_style if theme else None
|
|
if matching_bracket_style:
|
|
line.stylize(
|
|
matching_bracket_style,
|
|
cursor_column,
|
|
cursor_column + 1,
|
|
)
|
|
|
|
if self.suggestion and (self.has_focus or not self.hide_suggestion_on_blur):
|
|
suggestion_style = self.get_component_rich_style(
|
|
"text-area--suggestion"
|
|
)
|
|
line = Text.assemble(
|
|
line[:cursor_column],
|
|
(self.suggestion, suggestion_style),
|
|
line[cursor_column:],
|
|
)
|
|
|
|
if draw_cursor:
|
|
cursor_style = theme.cursor_style if theme else None
|
|
if cursor_style:
|
|
line.stylize(cursor_style, cursor_column, cursor_column + 1)
|
|
|
|
# Highlight the partner opening/closing bracket.
|
|
if draw_matched_brackets:
|
|
# mypy doesn't know matching bracket is guaranteed to be non-None
|
|
assert matching_bracket is not None
|
|
bracket_match_row, bracket_match_column = matching_bracket
|
|
if theme and bracket_match_row == line_index:
|
|
matching_bracket_style = theme.bracket_matching_style
|
|
if matching_bracket_style:
|
|
line.stylize(
|
|
matching_bracket_style,
|
|
bracket_match_column,
|
|
bracket_match_column + 1,
|
|
)
|
|
|
|
# Build the gutter text for this line
|
|
gutter_width = self.gutter_width
|
|
if self.show_line_numbers:
|
|
if cursor_row == line_index and highlight_cursor_line:
|
|
gutter_style = theme.cursor_line_gutter_style
|
|
else:
|
|
gutter_style = theme.gutter_style
|
|
|
|
gutter_width_no_margin = gutter_width - 2
|
|
gutter_content = (
|
|
str(line_index + self.line_number_start) if section_offset == 0 else ""
|
|
)
|
|
gutter = [
|
|
Segment(f"{gutter_content:>{gutter_width_no_margin}} ", gutter_style)
|
|
]
|
|
else:
|
|
gutter = []
|
|
|
|
# TODO: Lets not apply the division each time through render_line.
|
|
# We should cache sections with the edit counts.
|
|
wrap_offsets = wrapped_document.get_offsets(line_index)
|
|
if wrap_offsets:
|
|
sections = line.divide(wrap_offsets) # TODO cache result with edit count
|
|
line = sections[section_offset]
|
|
line_tab_widths = wrapped_document.get_tab_widths(line_index)
|
|
line.end = ""
|
|
|
|
# Get the widths of the tabs corresponding only to the section of the
|
|
# line that is currently being rendered. We don't care about tabs in
|
|
# other sections of the same line.
|
|
|
|
# Count the tabs before this section.
|
|
tabs_before = 0
|
|
for section_index in range(section_offset):
|
|
tabs_before += sections[section_index].plain.count("\t")
|
|
|
|
# Count the tabs in this section.
|
|
tabs_within = line.plain.count("\t")
|
|
section_tab_widths = line_tab_widths[
|
|
tabs_before : tabs_before + tabs_within
|
|
]
|
|
line = expand_text_tabs_from_widths(line, section_tab_widths)
|
|
else:
|
|
line.expand_tabs(self.indent_width)
|
|
|
|
base_width = (
|
|
self.scrollable_content_region.size.width
|
|
if self.soft_wrap
|
|
else max(virtual_width, self.region.size.width)
|
|
)
|
|
target_width = base_width - self.gutter_width
|
|
|
|
# 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)
|
|
|
|
# Stylize the line the cursor is currently on.
|
|
if cursor_row == line_index and self.highlight_cursor_line:
|
|
line_style = cursor_line_style
|
|
else:
|
|
line_style = theme.base_style if theme else None
|
|
|
|
text_strip = text_strip.extend_cell_length(target_width, line_style)
|
|
if gutter:
|
|
strip = Strip.join([Strip(gutter, cell_length=gutter_width), text_strip])
|
|
else:
|
|
strip = text_strip
|
|
|
|
return strip.apply_style(base_style)
|
|
|
|
@property
|
|
def text(self) -> str:
|
|
"""The entire text content of the document."""
|
|
return self.document.text
|
|
|
|
@text.setter
|
|
def text(self, value: str) -> None:
|
|
"""Replace the text currently in the TextArea. This is an alias of `load_text`.
|
|
|
|
Setting this value will clear the edit history.
|
|
|
|
Args:
|
|
value: The text to load into the TextArea.
|
|
"""
|
|
self.load_text(value)
|
|
|
|
@property
|
|
def selected_text(self) -> str:
|
|
"""The text between the start and end points of the current selection."""
|
|
start, end = self.selection
|
|
return self.get_text_range(start, end)
|
|
|
|
@property
|
|
def matching_bracket_location(self) -> Location | None:
|
|
"""The location of the matching bracket, if there is one."""
|
|
return self._matching_bracket_location
|
|
|
|
def get_text_range(self, start: Location, end: Location) -> str:
|
|
"""Get the text between a start and end location.
|
|
|
|
Args:
|
|
start: The start location.
|
|
end: The end location.
|
|
|
|
Returns:
|
|
The text between start and end.
|
|
"""
|
|
start, end = sorted((start, end))
|
|
return self.document.get_text_range(start, end)
|
|
|
|
def edit(self, edit: Edit) -> EditResult:
|
|
"""Perform an Edit.
|
|
|
|
Args:
|
|
edit: The Edit to perform.
|
|
|
|
Returns:
|
|
Data relating to the edit that may be useful. The data returned
|
|
may be different depending on the edit performed.
|
|
"""
|
|
if self.suggestion.startswith(edit.text):
|
|
self.suggestion = self.suggestion[len(edit.text) :]
|
|
else:
|
|
self.suggestion = ""
|
|
old_gutter_width = self.gutter_width
|
|
result = edit.do(self)
|
|
self.history.record(edit)
|
|
new_gutter_width = self.gutter_width
|
|
|
|
if old_gutter_width != new_gutter_width:
|
|
self.wrapped_document.wrap(self.wrap_width, self.indent_width)
|
|
else:
|
|
self.wrapped_document.wrap_range(
|
|
edit.top,
|
|
edit.bottom,
|
|
result.end_location,
|
|
)
|
|
|
|
edit.after(self)
|
|
self._build_highlight_map()
|
|
self.post_message(self.Changed(self))
|
|
self.update_suggestion()
|
|
self._refresh_size()
|
|
return result
|
|
|
|
def undo(self) -> None:
|
|
"""Undo the edits since the last checkpoint (the most recent batch of edits)."""
|
|
if edits := self.history._pop_undo():
|
|
self._undo_batch(edits)
|
|
|
|
def action_undo(self) -> None:
|
|
"""Undo the edits since the last checkpoint (the most recent batch of edits)."""
|
|
self.undo()
|
|
|
|
def redo(self) -> None:
|
|
"""Redo the most recently undone batch of edits."""
|
|
if edits := self.history._pop_redo():
|
|
self._redo_batch(edits)
|
|
|
|
def action_redo(self) -> None:
|
|
"""Redo the most recently undone batch of edits."""
|
|
self.redo()
|
|
|
|
def _undo_batch(self, edits: Sequence[Edit]) -> None:
|
|
"""Undo a batch of Edits.
|
|
|
|
The sequence must be chronologically ordered by edit time.
|
|
|
|
There must be no edits missing from the sequence, or the resulting content
|
|
will be incorrect.
|
|
|
|
Args:
|
|
edits: The edits to undo, in the order they were originally performed.
|
|
"""
|
|
if not edits:
|
|
return
|
|
|
|
old_gutter_width = self.gutter_width
|
|
minimum_top = edits[-1].top
|
|
maximum_old_bottom = (0, 0)
|
|
maximum_new_bottom = (0, 0)
|
|
for edit in reversed(edits):
|
|
edit.undo(self)
|
|
end_location = (
|
|
edit._edit_result.end_location if edit._edit_result else (0, 0)
|
|
)
|
|
if edit.top < minimum_top:
|
|
minimum_top = edit.top
|
|
if end_location > maximum_old_bottom:
|
|
maximum_old_bottom = end_location
|
|
if edit.bottom > maximum_new_bottom:
|
|
maximum_new_bottom = edit.bottom
|
|
|
|
new_gutter_width = self.gutter_width
|
|
if old_gutter_width != new_gutter_width:
|
|
self.wrapped_document.wrap(self.wrap_width, self.indent_width)
|
|
else:
|
|
self.wrapped_document.wrap_range(
|
|
minimum_top, maximum_old_bottom, maximum_new_bottom
|
|
)
|
|
|
|
self._refresh_size()
|
|
for edit in reversed(edits):
|
|
edit.after(self)
|
|
self._build_highlight_map()
|
|
self.post_message(self.Changed(self))
|
|
self.update_suggestion()
|
|
|
|
def _redo_batch(self, edits: Sequence[Edit]) -> None:
|
|
"""Redo a batch of Edits in order.
|
|
|
|
The sequence must be chronologically ordered by edit time.
|
|
|
|
Edits are applied from the start of the sequence to the end.
|
|
|
|
There must be no edits missing from the sequence, or the resulting content
|
|
will be incorrect.
|
|
|
|
Args:
|
|
edits: The edits to redo.
|
|
"""
|
|
if not edits:
|
|
return
|
|
|
|
old_gutter_width = self.gutter_width
|
|
minimum_top = edits[0].top
|
|
maximum_old_bottom = (0, 0)
|
|
maximum_new_bottom = (0, 0)
|
|
for edit in edits:
|
|
edit.do(self, record_selection=False)
|
|
end_location = (
|
|
edit._edit_result.end_location if edit._edit_result else (0, 0)
|
|
)
|
|
if edit.top < minimum_top:
|
|
minimum_top = edit.top
|
|
if end_location > maximum_new_bottom:
|
|
maximum_new_bottom = end_location
|
|
if edit.bottom > maximum_old_bottom:
|
|
maximum_old_bottom = edit.bottom
|
|
|
|
new_gutter_width = self.gutter_width
|
|
if old_gutter_width != new_gutter_width:
|
|
self.wrapped_document.wrap(self.wrap_width, self.indent_width)
|
|
else:
|
|
self.wrapped_document.wrap_range(
|
|
minimum_top,
|
|
maximum_old_bottom,
|
|
maximum_new_bottom,
|
|
)
|
|
|
|
self._refresh_size()
|
|
for edit in edits:
|
|
edit.after(self)
|
|
self._build_highlight_map()
|
|
self.post_message(self.Changed(self))
|
|
self.update_suggestion()
|
|
|
|
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",
|
|
}
|
|
if self.tab_behavior == "indent":
|
|
if key == "escape":
|
|
event.stop()
|
|
event.prevent_default()
|
|
self.screen.focus_next()
|
|
return
|
|
if self.indent_type == "tabs":
|
|
insert_values["tab"] = "\t"
|
|
else:
|
|
insert_values["tab"] = " " * self._find_columns_to_next_tab_stop()
|
|
|
|
if event.is_printable or key in insert_values:
|
|
event.stop()
|
|
event.prevent_default()
|
|
insert = insert_values.get(key, event.character)
|
|
# `insert` is not None because event.character cannot be
|
|
# None because we've checked that it's printable.
|
|
assert insert is not None
|
|
start, end = self.selection
|
|
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.
|
|
|
|
If the cursor is already at a tab stop, this returns the *next* tab stop location.
|
|
|
|
Returns:
|
|
The number of cells to the next tab stop from the current cursor column.
|
|
"""
|
|
cursor_row, cursor_column = self.cursor_location
|
|
line_text = self.document[cursor_row]
|
|
indent_width = self.indent_width
|
|
if not line_text:
|
|
return indent_width
|
|
|
|
width_before_cursor = self.get_column_width(cursor_row, cursor_column)
|
|
spaces_to_insert = indent_width - (
|
|
(indent_width + width_before_cursor) % indent_width
|
|
)
|
|
|
|
return spaces_to_insert
|
|
|
|
def get_target_document_location(self, event: MouseEvent) -> Location:
|
|
"""Given a MouseEvent, return the row and column offset of the event in document-space.
|
|
|
|
Args:
|
|
event: The MouseEvent.
|
|
|
|
Returns:
|
|
The location of the mouse event within the document.
|
|
"""
|
|
scroll_x, scroll_y = self.scroll_offset
|
|
target_x = event.x - self.gutter_width + scroll_x - self.gutter.left
|
|
target_y = event.y + scroll_y - self.gutter.top
|
|
location = self.wrapped_document.offset_to_location(Offset(target_x, target_y))
|
|
return location
|
|
|
|
@property
|
|
def gutter_width(self) -> int:
|
|
"""The width of the gutter (the left column containing line numbers).
|
|
|
|
Returns:
|
|
The cell-width of the line number column. If `show_line_numbers` is `False` returns 0.
|
|
"""
|
|
# The longest number in the gutter plus two extra characters: `│ `.
|
|
gutter_margin = 2
|
|
gutter_width = (
|
|
len(str(self.document.line_count - 1 + self.line_number_start))
|
|
+ gutter_margin
|
|
if self.show_line_numbers
|
|
else 0
|
|
)
|
|
return gutter_width
|
|
|
|
def _on_mount(self, event: events.Mount) -> None:
|
|
def text_selection_started(screen: Screen) -> None:
|
|
"""Signal callback to unselect when arbitrary text selection starts."""
|
|
self.selection = Selection(self.cursor_location, self.cursor_location)
|
|
|
|
self.screen.text_selection_started_signal.subscribe(
|
|
self, text_selection_started, immediate=True
|
|
)
|
|
|
|
# When `app.theme` reactive is changed, reset the theme to clear cached styles.
|
|
self.watch(self.app, "theme", self._app_theme_changed, init=False)
|
|
self.blink_timer = self.set_interval(
|
|
0.5,
|
|
self._toggle_cursor_blink_visible,
|
|
pause=not (self.cursor_blink and self.has_focus),
|
|
)
|
|
|
|
def _toggle_cursor_blink_visible(self) -> None:
|
|
"""Toggle visibility of the cursor for the purposes of 'cursor blink'."""
|
|
if not self.screen.is_active:
|
|
return
|
|
|
|
self._cursor_visible = not self._cursor_visible
|
|
_, 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:
|
|
self._cursor_visible = True
|
|
if self.is_mounted:
|
|
self.blink_timer.reset()
|
|
|
|
def _pause_blink(self, visible: bool = True) -> None:
|
|
"""Pause the cursor blinking but ensure it stays visible."""
|
|
self._cursor_visible = visible
|
|
if self.is_mounted:
|
|
self.blink_timer.pause()
|
|
|
|
async def _on_mouse_down(self, event: events.MouseDown) -> None:
|
|
"""Update the cursor position, and begin a selection using the mouse."""
|
|
target = self.get_target_document_location(event)
|
|
self.selection = Selection.cursor(target)
|
|
self._selecting = True
|
|
# 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=False)
|
|
self.history.checkpoint()
|
|
|
|
async def _on_mouse_move(self, event: events.MouseMove) -> None:
|
|
"""Handles click and drag to expand and contract the selection."""
|
|
if self._selecting:
|
|
target = self.get_target_document_location(event)
|
|
selection_start, _ = self.selection
|
|
self.selection = Selection(selection_start, target)
|
|
|
|
def _end_mouse_selection(self) -> None:
|
|
"""Finalize the selection that has been made using the mouse."""
|
|
if self._selecting:
|
|
self._selecting = False
|
|
self.release_mouse()
|
|
self.record_cursor_width()
|
|
self._restart_blink()
|
|
|
|
async def _on_mouse_up(self, event: events.MouseUp) -> None:
|
|
"""Finalize the selection that has been made using the mouse."""
|
|
self._end_mouse_selection()
|
|
|
|
async def _on_hide(self, event: events.Hide) -> None:
|
|
"""Finalize the selection that has been made using the mouse when the widget is hidden."""
|
|
self._end_mouse_selection()
|
|
|
|
async def _on_paste(self, event: events.Paste) -> None:
|
|
"""When a paste occurs, insert the text from the paste event into the document."""
|
|
if self.read_only:
|
|
return
|
|
if result := self._replace_via_keyboard(event.text, *self.selection):
|
|
self.move_cursor(result.end_location)
|
|
self.focus()
|
|
|
|
def cell_width_to_column_index(self, cell_width: int, row_index: int) -> int:
|
|
"""Return the column that the cell width corresponds to on the given row.
|
|
|
|
Args:
|
|
cell_width: The cell width to convert.
|
|
row_index: The index of the row to examine.
|
|
|
|
Returns:
|
|
The column corresponding to the cell width on that row.
|
|
"""
|
|
line = self.document[row_index]
|
|
return cell_width_to_column_index(line, cell_width, self.indent_width)
|
|
|
|
def clamp_visitable(self, location: Location) -> Location:
|
|
"""Clamp the given location to the nearest visitable location.
|
|
|
|
Args:
|
|
location: The location to clamp.
|
|
|
|
Returns:
|
|
The nearest location that we could conceivably navigate to using the cursor.
|
|
"""
|
|
document = self.document
|
|
|
|
row, column = location
|
|
try:
|
|
line_text = document[row]
|
|
except IndexError:
|
|
line_text = ""
|
|
|
|
row = clamp(row, 0, document.line_count - 1)
|
|
column = clamp(column, 0, len(line_text))
|
|
|
|
return row, column
|
|
|
|
# --- Cursor/selection utilities
|
|
def scroll_cursor_visible(
|
|
self, center: bool = False, animate: bool = False
|
|
) -> Offset:
|
|
"""Scroll the `TextArea` such that the cursor is visible on screen.
|
|
|
|
Args:
|
|
center: True if the cursor should be scrolled to the center.
|
|
animate: True if we should animate while scrolling.
|
|
|
|
Returns:
|
|
The offset that was scrolled to bring the cursor into view.
|
|
"""
|
|
if not self._has_cursor:
|
|
return Offset(0, 0)
|
|
self._recompute_cursor_offset()
|
|
|
|
x, y = self._cursor_offset
|
|
scroll_offset = self.scroll_to_region(
|
|
Region(x, y, width=3, height=1),
|
|
spacing=Spacing(right=self.gutter_width),
|
|
animate=animate,
|
|
force=True,
|
|
center=center,
|
|
)
|
|
return scroll_offset
|
|
|
|
def move_cursor(
|
|
self,
|
|
location: Location,
|
|
select: bool = False,
|
|
center: bool = False,
|
|
record_width: bool = True,
|
|
) -> None:
|
|
"""Move the cursor to a location.
|
|
|
|
Args:
|
|
location: The location to move the cursor to.
|
|
select: If True, select text between the old and new location.
|
|
center: If True, scroll such that the cursor is centered.
|
|
record_width: If True, record the cursor column cell width after navigating
|
|
so that we jump back to the same width the next time we move to a row
|
|
that is wide enough.
|
|
"""
|
|
if not self._has_cursor:
|
|
return
|
|
if select:
|
|
start, _end = self.selection
|
|
self.selection = Selection(start, location)
|
|
else:
|
|
self.selection = Selection.cursor(location)
|
|
|
|
if record_width:
|
|
self.record_cursor_width()
|
|
|
|
if center:
|
|
self.scroll_cursor_visible(center)
|
|
|
|
self.history.checkpoint()
|
|
|
|
def move_cursor_relative(
|
|
self,
|
|
rows: int = 0,
|
|
columns: int = 0,
|
|
select: bool = False,
|
|
center: bool = False,
|
|
record_width: bool = True,
|
|
) -> None:
|
|
"""Move the cursor relative to its current location in document-space.
|
|
|
|
Args:
|
|
rows: The number of rows to move down by (negative to move up)
|
|
columns: The number of columns to move right by (negative to move left)
|
|
select: If True, select text between the old and new location.
|
|
center: If True, scroll such that the cursor is centered.
|
|
record_width: If True, record the cursor column cell width after navigating
|
|
so that we jump back to the same width the next time we move to a row
|
|
that is wide enough.
|
|
"""
|
|
clamp_visitable = self.clamp_visitable
|
|
_start, end = self.selection
|
|
current_row, current_column = end
|
|
target = clamp_visitable((current_row + rows, current_column + columns))
|
|
self.move_cursor(target, select, center, record_width)
|
|
|
|
def select_line(self, index: int) -> None:
|
|
"""Select all the text in the specified line.
|
|
|
|
Args:
|
|
index: The index of the line to select (starting from 0).
|
|
"""
|
|
try:
|
|
line = self.document[index]
|
|
except IndexError:
|
|
return
|
|
else:
|
|
self.selection = Selection((index, 0), (index, len(line)))
|
|
self.record_cursor_width()
|
|
|
|
def action_select_line(self) -> None:
|
|
"""Select all the text on the current line."""
|
|
cursor_row, _ = self.cursor_location
|
|
self.select_line(cursor_row)
|
|
|
|
def select_all(self) -> None:
|
|
"""Select all of the text in the `TextArea`."""
|
|
last_line = self.document.line_count - 1
|
|
length_of_last_line = len(self.document[last_line])
|
|
selection_start = (0, 0)
|
|
selection_end = (last_line, length_of_last_line)
|
|
self.selection = Selection(selection_start, selection_end)
|
|
self.record_cursor_width()
|
|
|
|
def action_select_all(self) -> None:
|
|
"""Select all the text in the document."""
|
|
self.select_all()
|
|
|
|
@property
|
|
def cursor_location(self) -> Location:
|
|
"""The current location of the cursor in the document.
|
|
|
|
This is a utility for accessing the `end` of `TextArea.selection`.
|
|
"""
|
|
return self.selection.end
|
|
|
|
@cursor_location.setter
|
|
def cursor_location(self, location: Location) -> None:
|
|
"""Set the cursor_location to a new location.
|
|
|
|
If a selection is in progress, the anchor point will remain.
|
|
"""
|
|
self.move_cursor(location, select=not self.selection.is_empty)
|
|
|
|
@property
|
|
def cursor_screen_offset(self) -> Offset:
|
|
"""The offset of the cursor relative to the screen."""
|
|
cursor_x, cursor_y = self._cursor_offset
|
|
scroll_x, scroll_y = self.scroll_offset
|
|
region_x, region_y, _width, _height = self.content_region
|
|
|
|
offset_x = region_x + cursor_x - scroll_x + self.gutter_width
|
|
offset_y = region_y + cursor_y - scroll_y
|
|
|
|
return Offset(offset_x, offset_y)
|
|
|
|
@property
|
|
def cursor_at_first_line(self) -> bool:
|
|
"""True if and only if the cursor is on the first line."""
|
|
return self.selection.end[0] == 0
|
|
|
|
@property
|
|
def cursor_at_last_line(self) -> bool:
|
|
"""True if and only if the cursor is on the last line."""
|
|
return self.selection.end[0] == self.document.line_count - 1
|
|
|
|
@property
|
|
def cursor_at_start_of_line(self) -> bool:
|
|
"""True if and only if the cursor is at column 0."""
|
|
return self.selection.end[1] == 0
|
|
|
|
@property
|
|
def cursor_at_end_of_line(self) -> bool:
|
|
"""True if and only if the cursor is at the end of a row."""
|
|
cursor_row, cursor_column = self.selection.end
|
|
row_length = len(self.document[cursor_row])
|
|
cursor_at_end = cursor_column == row_length
|
|
return cursor_at_end
|
|
|
|
@property
|
|
def cursor_at_start_of_text(self) -> bool:
|
|
"""True if and only if the cursor is at location (0, 0)"""
|
|
return self.selection.end == (0, 0)
|
|
|
|
@property
|
|
def cursor_at_end_of_text(self) -> bool:
|
|
"""True if and only if the cursor is at the very end of the document."""
|
|
return self.cursor_at_last_line and self.cursor_at_end_of_line
|
|
|
|
# ------ Cursor movement actions
|
|
def action_cursor_left(self, select: bool = False) -> None:
|
|
"""Move the cursor one location to the left.
|
|
|
|
If the cursor is at the left edge of the document, try to move it to
|
|
the end of the previous line.
|
|
|
|
If text is selected, move the cursor to the start of the selection.
|
|
|
|
Args:
|
|
select: If True, select the text while moving.
|
|
"""
|
|
if not self._has_cursor:
|
|
self.scroll_left()
|
|
return
|
|
target = (
|
|
self.get_cursor_left_location()
|
|
if select or self.selection.is_empty
|
|
else min(*self.selection)
|
|
)
|
|
self.move_cursor(target, select=select)
|
|
|
|
def get_cursor_left_location(self) -> Location:
|
|
"""Get the location the cursor will move to if it moves left.
|
|
|
|
Returns:
|
|
The location of the cursor if it moves left.
|
|
"""
|
|
return self.navigator.get_location_left(self.cursor_location)
|
|
|
|
def action_cursor_right(self, select: bool = False) -> None:
|
|
"""Move the cursor one location to the right.
|
|
|
|
If the cursor is at the end of a line, attempt to go to the start of the next line.
|
|
|
|
If text is selected, move the cursor to the end of the selection.
|
|
|
|
Args:
|
|
select: If True, select the text while moving.
|
|
"""
|
|
if not self._has_cursor:
|
|
self.scroll_right()
|
|
return
|
|
if self.suggestion:
|
|
self.insert(self.suggestion)
|
|
return
|
|
target = (
|
|
self.get_cursor_right_location()
|
|
if select or self.selection.is_empty
|
|
else max(*self.selection)
|
|
)
|
|
self.move_cursor(target, select=select)
|
|
|
|
def get_cursor_right_location(self) -> Location:
|
|
"""Get the location the cursor will move to if it moves right.
|
|
|
|
Returns:
|
|
the location the cursor will move to if it moves right.
|
|
"""
|
|
return self.navigator.get_location_right(self.cursor_location)
|
|
|
|
def action_cursor_down(self, select: bool = False) -> None:
|
|
"""Move the cursor down one cell.
|
|
|
|
Args:
|
|
select: If True, select the text while moving.
|
|
"""
|
|
if not self._has_cursor:
|
|
self.scroll_down()
|
|
return
|
|
target = self.get_cursor_down_location()
|
|
self.move_cursor(target, record_width=False, select=select)
|
|
|
|
def get_cursor_down_location(self) -> Location:
|
|
"""Get the location the cursor will move to if it moves down.
|
|
|
|
Returns:
|
|
The location the cursor will move to if it moves down.
|
|
"""
|
|
return self.navigator.get_location_below(self.cursor_location)
|
|
|
|
def action_cursor_up(self, select: bool = False) -> None:
|
|
"""Move the cursor up one cell.
|
|
|
|
Args:
|
|
select: If True, select the text while moving.
|
|
"""
|
|
if not self._has_cursor:
|
|
self.scroll_up()
|
|
return
|
|
target = self.get_cursor_up_location()
|
|
self.move_cursor(target, record_width=False, select=select)
|
|
|
|
def get_cursor_up_location(self) -> Location:
|
|
"""Get the location the cursor will move to if it moves up.
|
|
|
|
Returns:
|
|
The location the cursor will move to if it moves up.
|
|
"""
|
|
return self.navigator.get_location_above(self.cursor_location)
|
|
|
|
def action_cursor_line_end(self, select: bool = False) -> None:
|
|
"""Move the cursor to the end of the line."""
|
|
if not self._has_cursor:
|
|
self.scroll_end()
|
|
return
|
|
location = self.get_cursor_line_end_location()
|
|
self.move_cursor(location, select=select)
|
|
|
|
def get_cursor_line_end_location(self) -> Location:
|
|
"""Get the location of the end of the current line.
|
|
|
|
Returns:
|
|
The (row, column) location of the end of the cursors current line.
|
|
"""
|
|
return self.navigator.get_location_end(self.cursor_location)
|
|
|
|
def action_cursor_line_start(self, select: bool = False) -> None:
|
|
"""Move the cursor to the start of the line."""
|
|
if not self._has_cursor:
|
|
self.scroll_home()
|
|
return
|
|
target = self.get_cursor_line_start_location(smart_home=True)
|
|
self.move_cursor(target, select=select)
|
|
|
|
def get_cursor_line_start_location(self, smart_home: bool = False) -> Location:
|
|
"""Get the location of the start of the current line.
|
|
|
|
Args:
|
|
smart_home: If True, use "smart home key" behavior - go to the first
|
|
non-whitespace character on the line, and if already there, go to
|
|
offset 0. Smart home only works when wrapping is disabled.
|
|
|
|
Returns:
|
|
The (row, column) location of the start of the cursors current line.
|
|
"""
|
|
return self.navigator.get_location_home(
|
|
self.cursor_location, smart_home=smart_home
|
|
)
|
|
|
|
def action_cursor_word_left(self, select: bool = False) -> None:
|
|
"""Move the cursor left by a single word, skipping trailing whitespace.
|
|
|
|
Args:
|
|
select: Whether to select while moving the cursor.
|
|
"""
|
|
if not self.show_cursor:
|
|
return
|
|
if self.cursor_at_start_of_text:
|
|
return
|
|
target = self.get_cursor_word_left_location()
|
|
self.move_cursor(target, select=select)
|
|
|
|
def get_cursor_word_left_location(self) -> Location:
|
|
"""Get the location the cursor will jump to if it goes 1 word left.
|
|
|
|
Returns:
|
|
The location the cursor will jump on "jump word left".
|
|
"""
|
|
cursor_row, cursor_column = self.cursor_location
|
|
if cursor_row > 0 and cursor_column == 0:
|
|
# Going to the previous row
|
|
return cursor_row - 1, len(self.document[cursor_row - 1])
|
|
|
|
# Staying on the same row
|
|
line = self.document[cursor_row][:cursor_column]
|
|
search_string = line.rstrip()
|
|
matches = list(re.finditer(self._word_pattern, search_string))
|
|
cursor_column = matches[-1].start() if matches else 0
|
|
return cursor_row, cursor_column
|
|
|
|
def action_cursor_word_right(self, select: bool = False) -> None:
|
|
"""Move the cursor right by a single word, skipping leading whitespace."""
|
|
if not self.show_cursor:
|
|
return
|
|
if self.cursor_at_end_of_text:
|
|
return
|
|
|
|
target = self.get_cursor_word_right_location()
|
|
self.move_cursor(target, select=select)
|
|
|
|
def get_cursor_word_right_location(self) -> Location:
|
|
"""Get the location the cursor will jump to if it goes 1 word right.
|
|
|
|
Returns:
|
|
The location the cursor will jump on "jump word right".
|
|
"""
|
|
cursor_row, cursor_column = self.selection.end
|
|
line = self.document[cursor_row]
|
|
if cursor_row < self.document.line_count - 1 and cursor_column == len(line):
|
|
# Moving to the line below
|
|
return cursor_row + 1, 0
|
|
|
|
# Staying on the same line
|
|
search_string = line[cursor_column:]
|
|
pre_strip_length = len(search_string)
|
|
search_string = search_string.lstrip()
|
|
strip_offset = pre_strip_length - len(search_string)
|
|
|
|
matches = list(re.finditer(self._word_pattern, search_string))
|
|
if matches:
|
|
cursor_column += matches[0].start() + strip_offset
|
|
else:
|
|
cursor_column = len(line)
|
|
|
|
return cursor_row, cursor_column
|
|
|
|
def action_cursor_page_up(self) -> None:
|
|
"""Move the cursor and scroll up one page."""
|
|
if not self.show_cursor:
|
|
self.scroll_page_up()
|
|
return
|
|
height = self.content_size.height
|
|
_, cursor_location = self.selection
|
|
target = self.navigator.get_location_at_y_offset(
|
|
cursor_location,
|
|
-height,
|
|
)
|
|
self.scroll_relative(y=-height, animate=False)
|
|
self.move_cursor(target)
|
|
|
|
def action_cursor_page_down(self) -> None:
|
|
"""Move the cursor and scroll down one page."""
|
|
if not self.show_cursor:
|
|
self.scroll_page_down()
|
|
return
|
|
height = self.content_size.height
|
|
_, cursor_location = self.selection
|
|
target = self.navigator.get_location_at_y_offset(
|
|
cursor_location,
|
|
height,
|
|
)
|
|
self.scroll_relative(y=height, animate=False)
|
|
self.move_cursor(target)
|
|
|
|
def get_column_width(self, row: int, column: int) -> int:
|
|
"""Get the cell offset of the column from the start of the row.
|
|
|
|
Args:
|
|
row: The row index.
|
|
column: The column index (codepoint offset from start of row).
|
|
|
|
Returns:
|
|
The cell width of the column relative to the start of the row.
|
|
"""
|
|
line = self.document[row]
|
|
return cell_len(expand_tabs_inline(line[:column], self.indent_width))
|
|
|
|
def record_cursor_width(self) -> None:
|
|
"""Record the current cell width of the cursor.
|
|
|
|
This is used where we navigate up and down through rows.
|
|
If we're in the middle of a row, and go down to a row with no
|
|
content, then we go down to another row, we want our cursor to
|
|
jump back to the same offset that we were originally at.
|
|
"""
|
|
cursor_x_offset, _ = self.wrapped_document.location_to_offset(
|
|
self.cursor_location
|
|
)
|
|
self.navigator.last_x_offset = cursor_x_offset
|
|
|
|
# --- Editor operations
|
|
def insert(
|
|
self,
|
|
text: str,
|
|
location: Location | None = None,
|
|
*,
|
|
maintain_selection_offset: bool = True,
|
|
) -> EditResult:
|
|
"""Insert text into the document.
|
|
|
|
Args:
|
|
text: The text to insert.
|
|
location: The location to insert text, or None to use the cursor location.
|
|
maintain_selection_offset: If True, the active Selection will be updated
|
|
such that the same text is selected before and after the selection,
|
|
if possible. Otherwise, the cursor will jump to the end point of the
|
|
edit.
|
|
|
|
Returns:
|
|
An `EditResult` containing information about the edit.
|
|
"""
|
|
if len(text) > 1:
|
|
self._restart_blink()
|
|
if location is None:
|
|
location = self.cursor_location
|
|
return self.edit(Edit(text, location, location, maintain_selection_offset))
|
|
|
|
def delete(
|
|
self,
|
|
start: Location,
|
|
end: Location,
|
|
*,
|
|
maintain_selection_offset: bool = True,
|
|
) -> EditResult:
|
|
"""Delete the text between two locations in the document.
|
|
|
|
Args:
|
|
start: The start location.
|
|
end: The end location.
|
|
maintain_selection_offset: If True, the active Selection will be updated
|
|
such that the same text is selected before and after the selection,
|
|
if possible. Otherwise, the cursor will jump to the end point of the
|
|
edit.
|
|
|
|
Returns:
|
|
An `EditResult` containing information about the edit.
|
|
"""
|
|
return self.edit(Edit("", start, end, maintain_selection_offset))
|
|
|
|
def replace(
|
|
self,
|
|
insert: str,
|
|
start: Location,
|
|
end: Location,
|
|
*,
|
|
maintain_selection_offset: bool = True,
|
|
) -> EditResult:
|
|
"""Replace text in the document with new text.
|
|
|
|
Args:
|
|
insert: The text to insert.
|
|
start: The start location
|
|
end: The end location.
|
|
maintain_selection_offset: If True, the active Selection will be updated
|
|
such that the same text is selected before and after the selection,
|
|
if possible. Otherwise, the cursor will jump to the end point of the
|
|
edit.
|
|
|
|
Returns:
|
|
An `EditResult` containing information about the edit.
|
|
"""
|
|
return self.edit(Edit(insert, start, end, maintain_selection_offset))
|
|
|
|
def clear(self) -> EditResult:
|
|
"""Delete all text from the document.
|
|
|
|
Returns:
|
|
An EditResult relating to the deletion of all content.
|
|
"""
|
|
return self.delete((0, 0), self.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.
|
|
|
|
If there's a selection, then the selected range is deleted."""
|
|
|
|
if self.read_only:
|
|
return
|
|
|
|
selection = self.selection
|
|
start, end = selection
|
|
|
|
if selection.is_empty:
|
|
end = self.get_cursor_left_location()
|
|
|
|
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.
|
|
|
|
If there's a selection, then the selected range is deleted."""
|
|
if self.read_only:
|
|
return
|
|
|
|
selection = self.selection
|
|
start, end = selection
|
|
|
|
if selection.is_empty:
|
|
end = self.get_cursor_right_location()
|
|
|
|
self._delete_via_keyboard(start, end)
|
|
|
|
def action_delete_line(self) -> None:
|
|
"""Deletes the lines which intersect with the selection."""
|
|
if self.read_only:
|
|
return
|
|
self._delete_cursor_line()
|
|
|
|
def _delete_cursor_line(self) -> EditResult | None:
|
|
"""Deletes the line (including the line terminator) that the cursor is on."""
|
|
start, end = self.selection
|
|
start, end = sorted((start, end))
|
|
start_row, _start_column = start
|
|
end_row, end_column = end
|
|
|
|
# Generally editors will only delete line the end line of the
|
|
# selection if the cursor is not at column 0 of that line.
|
|
if start_row != end_row and end_column == 0 and end_row >= 0:
|
|
end_row -= 1
|
|
|
|
from_location = (start_row, 0)
|
|
to_location = (end_row + 1, 0)
|
|
|
|
deletion = self._delete_via_keyboard(from_location, to_location)
|
|
if deletion is not None:
|
|
self.move_cursor_relative(columns=end_column, record_width=False)
|
|
return deletion
|
|
|
|
def action_cut(self) -> None:
|
|
"""Cut text (remove and copy to clipboard)."""
|
|
if self.read_only:
|
|
return
|
|
start, end = self.selection
|
|
if start == end:
|
|
edit_result = self._delete_cursor_line()
|
|
else:
|
|
edit_result = self._delete_via_keyboard(start, end)
|
|
|
|
if edit_result is not None:
|
|
self.app.copy_to_clipboard(edit_result.replaced_text)
|
|
|
|
def action_copy(self) -> None:
|
|
"""Copy selection to clipboard."""
|
|
selected_text = self.selected_text
|
|
if selected_text:
|
|
self.app.copy_to_clipboard(selected_text)
|
|
else:
|
|
raise SkipAction()
|
|
|
|
def action_paste(self) -> None:
|
|
"""Paste from local clipboard."""
|
|
if self.read_only:
|
|
return
|
|
clipboard = self.app.clipboard
|
|
if result := self._replace_via_keyboard(clipboard, *self.selection):
|
|
self.move_cursor(result.end_location)
|
|
|
|
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_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_via_keyboard(from_location, to_location)
|
|
|
|
async def action_delete_to_end_of_line_or_delete_line(self) -> None:
|
|
"""Deletes from the cursor location to the end of the line, or deletes the line.
|
|
|
|
The line will be deleted if the line is empty.
|
|
"""
|
|
# Assume we're just going to delete to the end of the line.
|
|
action = "delete_to_end_of_line"
|
|
if self.get_cursor_line_start_location() == self.get_cursor_line_end_location():
|
|
# The line is empty, so we'll simply remove the line itself.
|
|
action = "delete_line"
|
|
elif (
|
|
self.selection.start
|
|
== self.selection.end
|
|
== self.get_cursor_line_end_location()
|
|
):
|
|
# We're at the end of the line, so the kill delete operation
|
|
# should join the next line to this.
|
|
action = "delete_right"
|
|
await self.run_action(action)
|
|
|
|
def action_delete_word_left(self) -> None:
|
|
"""Deletes the word to the left of the cursor and updates the cursor location."""
|
|
if self.cursor_at_start_of_text:
|
|
return
|
|
|
|
# If there's a non-zero selection, then "delete word left" typically only
|
|
# deletes the characters within the selection range, ignoring word boundaries.
|
|
start, end = self.selection
|
|
if start != end:
|
|
self._delete_via_keyboard(start, end)
|
|
return
|
|
|
|
to_location = self.get_cursor_word_left_location()
|
|
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.
|
|
|
|
Note that the location that we delete to using this action is not the same
|
|
as the location we move to when we move the cursor one word to the right.
|
|
This action does not skip leading whitespace, whereas cursor movement does.
|
|
"""
|
|
if self.cursor_at_end_of_text:
|
|
return
|
|
|
|
start, end = self.selection
|
|
if start != end:
|
|
self._delete_via_keyboard(start, end)
|
|
return
|
|
|
|
cursor_row, cursor_column = end
|
|
|
|
# Check the current line for a word boundary
|
|
line = self.document[cursor_row][cursor_column:]
|
|
matches = list(re.finditer(self._word_pattern, line))
|
|
|
|
current_row_length = len(self.document[cursor_row])
|
|
if matches:
|
|
to_location = (cursor_row, cursor_column + matches[0].end())
|
|
elif (
|
|
cursor_row < self.document.line_count - 1
|
|
and cursor_column == current_row_length
|
|
):
|
|
to_location = (cursor_row + 1, 0)
|
|
else:
|
|
to_location = (cursor_row, current_row_length)
|
|
|
|
self._delete_via_keyboard(end, to_location)
|
|
|
|
|
|
@lru_cache(maxsize=128)
|
|
def build_byte_to_codepoint_dict(data: bytes) -> dict[int, int]:
|
|
"""Build a mapping of utf-8 byte offsets to codepoint offsets for the given data.
|
|
|
|
Args:
|
|
data: utf-8 bytes.
|
|
|
|
Returns:
|
|
A `dict[int, int]` mapping byte indices to codepoint indices within `data`.
|
|
"""
|
|
byte_to_codepoint: dict[int, int] = {}
|
|
current_byte_offset = 0
|
|
code_point_offset = 0
|
|
|
|
while current_byte_offset < len(data):
|
|
byte_to_codepoint[current_byte_offset] = code_point_offset
|
|
first_byte = data[current_byte_offset]
|
|
|
|
# Single-byte character
|
|
if (first_byte & 0b10000000) == 0:
|
|
current_byte_offset += 1
|
|
# 2-byte character
|
|
elif (first_byte & 0b11100000) == 0b11000000:
|
|
current_byte_offset += 2
|
|
# 3-byte character
|
|
elif (first_byte & 0b11110000) == 0b11100000:
|
|
current_byte_offset += 3
|
|
# 4-byte character
|
|
elif (first_byte & 0b11111000) == 0b11110000:
|
|
current_byte_offset += 4
|
|
else:
|
|
raise ValueError(f"Invalid UTF-8 byte: {first_byte}")
|
|
|
|
code_point_offset += 1
|
|
|
|
# Mapping for the end of the string
|
|
byte_to_codepoint[current_byte_offset] = code_point_offset
|
|
return byte_to_codepoint
|