Adding upstream version 0.15.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
0184169650
commit
c6da052ee9
47 changed files with 6799 additions and 0 deletions
0
src/scripts/__init__.py
Normal file
0
src/scripts/__init__.py
Normal file
23
src/scripts/profile_startup.py
Normal file
23
src/scripts/profile_startup.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual_textarea import TextEditor
|
||||
|
||||
|
||||
class TextApp(App, inherit_bindings=False):
|
||||
def compose(self) -> ComposeResult:
|
||||
self.ta = TextEditor(
|
||||
text="class TextApp(App):",
|
||||
language="python",
|
||||
theme="monokai",
|
||||
use_system_clipboard=True,
|
||||
id="ta",
|
||||
)
|
||||
yield self.ta
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.ta.focus()
|
||||
self.exit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = TextApp()
|
||||
app.run()
|
20
src/scripts/sample_code.py
Normal file
20
src/scripts/sample_code.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from textual.app import App, ComposeResult
|
||||
from textual_textarea import TextEditor
|
||||
|
||||
|
||||
class TextApp(App, inherit_bindings=False):
|
||||
def compose(self) -> ComposeResult:
|
||||
self.editor = TextEditor(
|
||||
language="python",
|
||||
theme="monokai",
|
||||
use_system_clipboard=True,
|
||||
)
|
||||
yield self.editor
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.editor.focus()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = TextApp()
|
||||
app.run()
|
32
src/scripts/screenshot.py
Normal file
32
src/scripts/screenshot.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets.text_area import Selection
|
||||
from textual_textarea import TextEditor
|
||||
|
||||
contents = (Path(__file__).parent / "sample_code.py").open("r").read()
|
||||
|
||||
|
||||
class TextApp(App, inherit_bindings=False):
|
||||
def compose(self) -> ComposeResult:
|
||||
self.editor = TextEditor(
|
||||
language="python",
|
||||
theme="monokai",
|
||||
use_system_clipboard=True,
|
||||
)
|
||||
yield self.editor
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.editor.focus()
|
||||
|
||||
|
||||
async def take_screenshot() -> None:
|
||||
app = TextApp()
|
||||
async with app.run_test(size=(80, 24)):
|
||||
app.editor.text = contents
|
||||
app.editor.selection = Selection((7, 12), (8, 12))
|
||||
app.save_screenshot("textarea.svg")
|
||||
|
||||
|
||||
asyncio.run(take_screenshot())
|
15
src/textual_textarea/__init__.py
Normal file
15
src/textual_textarea/__init__.py
Normal file
|
@ -0,0 +1,15 @@
|
|||
from textual_textarea.messages import (
|
||||
TextAreaClipboardError,
|
||||
TextAreaSaved,
|
||||
TextAreaThemeError,
|
||||
)
|
||||
from textual_textarea.path_input import PathInput
|
||||
from textual_textarea.text_editor import TextEditor
|
||||
|
||||
__all__ = [
|
||||
"TextEditor",
|
||||
"PathInput",
|
||||
"TextAreaClipboardError",
|
||||
"TextAreaThemeError",
|
||||
"TextAreaSaved",
|
||||
]
|
67
src/textual_textarea/__main__.py
Normal file
67
src/textual_textarea/__main__.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Footer, Placeholder
|
||||
|
||||
from textual_textarea import TextEditor
|
||||
|
||||
|
||||
class FocusablePlaceholder(Placeholder, can_focus=True):
|
||||
pass
|
||||
|
||||
|
||||
class TextApp(App, inherit_bindings=False):
|
||||
BINDINGS = [("ctrl+q", "quit")]
|
||||
CSS = """
|
||||
TextEditor {
|
||||
height: 1fr;
|
||||
}
|
||||
Placeholder {
|
||||
height: 0fr;
|
||||
}
|
||||
"""
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
try:
|
||||
language = sys.argv[1]
|
||||
except IndexError:
|
||||
language = "sql"
|
||||
yield FocusablePlaceholder()
|
||||
self.editor = TextEditor(
|
||||
language=language,
|
||||
use_system_clipboard=True,
|
||||
id="ta",
|
||||
)
|
||||
yield self.editor
|
||||
yield Footer()
|
||||
|
||||
def watch_theme(self, theme: str) -> None:
|
||||
self.editor.theme = theme
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.theme = "gruvbox"
|
||||
self.editor.focus()
|
||||
|
||||
def _completer(prefix: str) -> list[tuple[tuple[str, str], str]]:
|
||||
words = [
|
||||
"satisfy",
|
||||
"season",
|
||||
"second",
|
||||
"seldom",
|
||||
"select",
|
||||
"self",
|
||||
"separate",
|
||||
"set",
|
||||
"space",
|
||||
"super",
|
||||
"supercalifragilisticexpialadocioussupercalifragilisticexpialadocious",
|
||||
]
|
||||
return [((w, "word"), w) for w in words if w.startswith(prefix)]
|
||||
|
||||
self.editor.word_completer = _completer
|
||||
|
||||
|
||||
app = TextApp()
|
||||
app.run()
|
289
src/textual_textarea/autocomplete.py
Normal file
289
src/textual_textarea/autocomplete.py
Normal file
|
@ -0,0 +1,289 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from textual import on, work
|
||||
from textual.css.scalar import Scalar, ScalarOffset, Unit
|
||||
from textual.events import Key, Resize
|
||||
from textual.geometry import Size
|
||||
from textual.message import Message
|
||||
from textual.reactive import Reactive, reactive
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets._option_list import NewOptionListContent
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
from textual_textarea.messages import TextAreaHideCompletionList
|
||||
|
||||
|
||||
class Completion(Option):
|
||||
def __init__(
|
||||
self,
|
||||
prompt: RenderableType,
|
||||
id: str | None = None, # noqa: A002
|
||||
disabled: bool = False,
|
||||
value: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(prompt, id, disabled)
|
||||
self.value = value
|
||||
|
||||
|
||||
class CompletionList(OptionList, can_focus=False, inherit_bindings=False):
|
||||
COMPONENT_CLASSES = {
|
||||
"completion-list--type-label",
|
||||
"completion-list--type-label-highlighted",
|
||||
}
|
||||
DEFAULT_CSS = """
|
||||
CompletionList {
|
||||
layer: overlay;
|
||||
padding: 0;
|
||||
border: none;
|
||||
width: 40;
|
||||
max-height: 8;
|
||||
display: none;
|
||||
}
|
||||
CompletionList.open {
|
||||
display: block;
|
||||
}
|
||||
CompletionList .completion-list--type-label {
|
||||
color: $foreground-muted;
|
||||
background: transparent;
|
||||
}
|
||||
"""
|
||||
|
||||
class CompletionsReady(Message, bubble=False):
|
||||
def __init__(
|
||||
self,
|
||||
prefix: str,
|
||||
items: list[tuple[str, str]] | list[tuple[tuple[str, str], str]],
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.items = items
|
||||
self.prefix = prefix
|
||||
|
||||
INNER_CONTENT_WIDTH = 37 # should be 3 less than width for scroll bar.
|
||||
is_open: Reactive[bool] = reactive(False)
|
||||
cursor_offset: tuple[int, int] = (0, 0)
|
||||
additional_x_offset: int = 0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*content: NewOptionListContent,
|
||||
name: str | None = None,
|
||||
id: str | None = None, # noqa: A002
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
):
|
||||
super().__init__(
|
||||
*content, name=name, id=id, classes=classes, disabled=disabled, wrap=False
|
||||
)
|
||||
|
||||
def set_offset(self, x_offset: int, y_offset: int) -> None:
|
||||
"""The CSS Offset of this widget from its parent."""
|
||||
self.styles.offset = ScalarOffset.from_offset(
|
||||
(
|
||||
x_offset,
|
||||
y_offset,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def x_offset(self) -> int:
|
||||
"""The x-coord of the CSS Offset of this widget from its parent."""
|
||||
return int(self.styles.offset.x.value)
|
||||
|
||||
@property
|
||||
def y_offset(self) -> int:
|
||||
"""The y-coord of the CSS Offset of this widget from its parent."""
|
||||
return int(self.styles.offset.y.value)
|
||||
|
||||
@property
|
||||
def parent_height(self) -> int:
|
||||
"""
|
||||
The content size height of the parent widget
|
||||
"""
|
||||
return self.parent_size.height
|
||||
|
||||
@property
|
||||
def parent_width(self) -> int:
|
||||
"""
|
||||
The content size height of the parent widget
|
||||
"""
|
||||
return self.parent_size.width
|
||||
|
||||
@property
|
||||
def parent_size(self) -> Size:
|
||||
"""
|
||||
The content size of the parent widget
|
||||
"""
|
||||
parent = self.parent
|
||||
if isinstance(parent, Widget):
|
||||
return parent.content_size
|
||||
else:
|
||||
return self.screen.content_size
|
||||
|
||||
@on(CompletionsReady)
|
||||
def populate_and_position_list(self, event: CompletionsReady) -> None:
|
||||
event.stop()
|
||||
self.clear_options()
|
||||
type_label_style_full = self.get_component_rich_style(
|
||||
"completion-list--type-label"
|
||||
)
|
||||
type_label_fg_style = Style(color=type_label_style_full.color)
|
||||
prompts = [
|
||||
Text.assemble(item[0][0], " ", (item[0][1], type_label_fg_style))
|
||||
if isinstance(item[0], tuple)
|
||||
else Text.from_markup(item[0])
|
||||
for item in event.items
|
||||
]
|
||||
|
||||
# if the completions' prompts are wider than the widget,
|
||||
# we have to trunctate them
|
||||
max_length = max(map(lambda x: x.cell_len, prompts))
|
||||
truncate_amount = max(
|
||||
0,
|
||||
min(
|
||||
max_length - self.INNER_CONTENT_WIDTH,
|
||||
len(event.prefix) - 2,
|
||||
),
|
||||
)
|
||||
if truncate_amount > 0:
|
||||
additional_x_offset = truncate_amount - 1
|
||||
items = [
|
||||
Completion(prompt=f"…{prompt[truncate_amount:]}", value=item[1])
|
||||
for prompt, item in zip(prompts, event.items)
|
||||
]
|
||||
else:
|
||||
additional_x_offset = 0
|
||||
items = [
|
||||
Completion(prompt=prompt, value=item[1])
|
||||
for prompt, item in zip(prompts, event.items)
|
||||
]
|
||||
|
||||
# set x offset if not already open.
|
||||
if not self.is_open:
|
||||
try:
|
||||
x_offset = self._get_x_offset(
|
||||
prefix_length=len(event.prefix),
|
||||
additional_x_offset=additional_x_offset,
|
||||
cursor_x=self.cursor_offset[0],
|
||||
container_width=self.parent_width,
|
||||
width=self._width,
|
||||
)
|
||||
except ValueError:
|
||||
x_offset = 0
|
||||
self.styles.width = self._parent_container_size.width
|
||||
self.set_offset(x_offset, self.y_offset)
|
||||
# adjust x offset if we have to due to truncation
|
||||
elif additional_x_offset != self.additional_x_offset:
|
||||
self.set_offset(
|
||||
min(
|
||||
self.x_offset + (additional_x_offset - self.additional_x_offset),
|
||||
self.parent_width - self._width,
|
||||
),
|
||||
self.y_offset,
|
||||
)
|
||||
|
||||
self.add_options(items=items)
|
||||
self.action_first()
|
||||
self.additional_x_offset = additional_x_offset
|
||||
self.is_open = True
|
||||
|
||||
def watch_is_open(self, is_open: bool) -> None:
|
||||
if not is_open:
|
||||
self.remove_class("open")
|
||||
self.additional_x_offset = 0
|
||||
return
|
||||
|
||||
self.add_class("open")
|
||||
self.styles.max_height = Scalar(
|
||||
value=8.0, unit=Unit.CELLS, percent_unit=Unit.PERCENT
|
||||
)
|
||||
|
||||
def on_resize(self, event: Resize) -> None:
|
||||
try:
|
||||
y_offset = self._get_y_offset(
|
||||
cursor_y=self.cursor_offset[1],
|
||||
height=event.size.height,
|
||||
container_height=self.parent_height,
|
||||
)
|
||||
except ValueError:
|
||||
if self.styles.max_height is not None and self.styles.max_height.value > 1:
|
||||
self.styles.max_height = Scalar(
|
||||
value=self.styles.max_height.value - 1,
|
||||
unit=self.styles.max_height.unit,
|
||||
percent_unit=self.styles.max_height.percent_unit,
|
||||
)
|
||||
else:
|
||||
self.post_message(TextAreaHideCompletionList())
|
||||
else:
|
||||
self.set_offset(self.x_offset, y_offset)
|
||||
|
||||
@work(thread=True, exclusive=True, group="completers")
|
||||
def show_completions(
|
||||
self,
|
||||
prefix: str,
|
||||
completer: Callable[
|
||||
[str], list[tuple[str, str]] | list[tuple[tuple[str, str], str]]
|
||||
]
|
||||
| None,
|
||||
) -> None:
|
||||
matches = completer(prefix) if completer is not None else []
|
||||
if matches:
|
||||
self.post_message(self.CompletionsReady(prefix=prefix, items=matches))
|
||||
else:
|
||||
self.post_message(TextAreaHideCompletionList())
|
||||
|
||||
def process_keypress(self, event: Key) -> None:
|
||||
if event.key in ("tab", "enter", "shift+tab"):
|
||||
self.action_select()
|
||||
elif event.key == "up":
|
||||
self.action_cursor_up()
|
||||
elif event.key == "down":
|
||||
self.action_cursor_down()
|
||||
elif event.key == "pageup":
|
||||
self.action_page_up()
|
||||
elif event.key == "pagedown":
|
||||
self.action_page_down()
|
||||
|
||||
@property
|
||||
def _parent_container_size(self) -> Size:
|
||||
return getattr(self.parent, "container_size", self.screen.container_size)
|
||||
|
||||
@property
|
||||
def _width(self) -> int:
|
||||
if self.styles.width and self.styles.width.unit == Unit.CELLS:
|
||||
return int(self.styles.width.value)
|
||||
else:
|
||||
return self.outer_size.width
|
||||
|
||||
@staticmethod
|
||||
def _get_x_offset(
|
||||
prefix_length: int,
|
||||
additional_x_offset: int,
|
||||
cursor_x: int,
|
||||
container_width: int,
|
||||
width: int,
|
||||
) -> int:
|
||||
x = cursor_x - prefix_length + additional_x_offset
|
||||
max_x = container_width - width
|
||||
if max_x < 0:
|
||||
raise ValueError("doesn't fit")
|
||||
|
||||
return min(x, max_x)
|
||||
|
||||
@staticmethod
|
||||
def _get_y_offset(cursor_y: int, height: int, container_height: int) -> int:
|
||||
fits_above = height < cursor_y + 1
|
||||
fits_below = height < container_height - cursor_y
|
||||
if fits_below:
|
||||
y = cursor_y + 1
|
||||
elif fits_above:
|
||||
y = cursor_y - height
|
||||
else:
|
||||
raise ValueError("Doesn't fit.")
|
||||
|
||||
return y
|
19
src/textual_textarea/cancellable_input.py
Normal file
19
src/textual_textarea/cancellable_input.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
from textual.binding import Binding
|
||||
from textual.message import Message
|
||||
from textual.widgets import Input
|
||||
|
||||
|
||||
class CancellableInput(Input):
|
||||
BINDINGS = [
|
||||
Binding("escape", "cancel", "Cancel", show=False),
|
||||
]
|
||||
|
||||
class Cancelled(Message):
|
||||
"""
|
||||
Posted when the user presses Esc to cancel the input.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
def action_cancel(self) -> None:
|
||||
self.post_message(self.Cancelled())
|
67
src/textual_textarea/colors.py
Normal file
67
src/textual_textarea/colors.py
Normal file
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from rich.style import Style
|
||||
from textual.color import Color
|
||||
from textual.theme import Theme
|
||||
from textual.widgets.text_area import TextAreaTheme
|
||||
|
||||
|
||||
def text_area_theme_from_app_theme(
|
||||
theme_name: str, theme: Theme, css_vars: dict[str, str]
|
||||
) -> TextAreaTheme:
|
||||
builtin = TextAreaTheme.get_builtin_theme(theme_name)
|
||||
if builtin is not None:
|
||||
return builtin
|
||||
|
||||
if "background" in css_vars:
|
||||
background_color = Color.parse(
|
||||
css_vars.get("background", "#000000" if theme.dark else "#FFFFFF")
|
||||
)
|
||||
foreground_color = Color.parse(
|
||||
css_vars.get("foreground", background_color.inverse)
|
||||
)
|
||||
else:
|
||||
foreground_color = Color.parse(
|
||||
css_vars.get("foreground", "#FFFFFF" if theme.dark else "#000000")
|
||||
)
|
||||
background_color = foreground_color.inverse
|
||||
|
||||
muted = background_color.blend(foreground_color, factor=0.5)
|
||||
|
||||
computed_theme = TextAreaTheme(
|
||||
name=theme_name,
|
||||
base_style=Style(
|
||||
color=foreground_color.rich_color, bgcolor=background_color.rich_color
|
||||
),
|
||||
syntax_styles={
|
||||
"comment": muted.hex, # type: ignore
|
||||
"string": theme.accent, # type: ignore
|
||||
"string.documentation": muted.hex, # type: ignore
|
||||
"string.special": theme.accent, # type: ignore
|
||||
"number": theme.accent, # type: ignore
|
||||
"float": theme.accent, # type: ignore
|
||||
"function": theme.secondary, # type: ignore
|
||||
"function.call": theme.secondary, # type: ignore
|
||||
"method": theme.secondary, # type: ignore
|
||||
"method.call": theme.secondary, # type: ignore
|
||||
"constant": foreground_color.hex, # type: ignore
|
||||
"constant.builtin": foreground_color.hex, # type: ignore
|
||||
"boolean": theme.accent, # type: ignore
|
||||
"class": f"{foreground_color.hex} bold", # type: ignore
|
||||
"type": f"{foreground_color.hex} bold", # type: ignore
|
||||
"variable": foreground_color.hex, # type: ignore
|
||||
"parameter": f"{theme.accent} bold", # type: ignore
|
||||
"operator": theme.secondary, # type: ignore
|
||||
"punctuation.bracket": foreground_color.hex, # type: ignore
|
||||
"punctuation.delimeter": foreground_color.hex, # type: ignore
|
||||
"keyword": f"{theme.primary} bold", # type: ignore
|
||||
"keyword.function": theme.secondary, # type: ignore
|
||||
"keyword.return": theme.primary, # type: ignore
|
||||
"keyword.operator": f"{theme.primary} bold", # type: ignore
|
||||
"exception": theme.error, # type: ignore
|
||||
"heading": theme.primary, # type: ignore
|
||||
"bold": "bold", # type: ignore
|
||||
"italic": "italic", # type: ignore
|
||||
},
|
||||
)
|
||||
return computed_theme
|
185
src/textual_textarea/comments.py
Normal file
185
src/textual_textarea/comments.py
Normal file
|
@ -0,0 +1,185 @@
|
|||
INLINE_MARKERS = {
|
||||
"abap": '"',
|
||||
"actionscript": "//",
|
||||
"as": "//",
|
||||
"actionscript3": "//",
|
||||
"as3": "//",
|
||||
"ada": "--",
|
||||
"ada95": "--",
|
||||
"ada2005": "--",
|
||||
"antlr-objc": "//",
|
||||
"apl": "⍝",
|
||||
"applescript": "--",
|
||||
"autohotkey": ";",
|
||||
"ahk": ";",
|
||||
"autoit": ";",
|
||||
"basemake": "#",
|
||||
"bash": "#",
|
||||
"sh": "#",
|
||||
"ksh": "#",
|
||||
"zsh": "#",
|
||||
"shell": "#",
|
||||
"batch": "::",
|
||||
"bat": "::",
|
||||
"dosbatch": "::",
|
||||
"winbatch": "::",
|
||||
"bbcbasic": "REM",
|
||||
"blitzbasic": "REM",
|
||||
"b3d": "REM",
|
||||
"bplus": "REM",
|
||||
"boo": "#",
|
||||
"c": "//",
|
||||
"csharp": "//",
|
||||
"c#": "//",
|
||||
"cs": "//",
|
||||
"cpp": "//",
|
||||
"c++": "//",
|
||||
"cbmbas": "REM",
|
||||
"clojure": ";",
|
||||
"clj": ";",
|
||||
"clojurescript": ";",
|
||||
"cljs": ";",
|
||||
"cmake": "#",
|
||||
"cobol": "*>",
|
||||
"cobolfree": "*>",
|
||||
"common-lisp": ";",
|
||||
"cl": ";",
|
||||
"lisp": ";",
|
||||
"d": "//",
|
||||
"delphi": "//",
|
||||
"pas": "//",
|
||||
"pascal": "//",
|
||||
"objectpascal": "//",
|
||||
"eiffel": "--",
|
||||
"elixir": "#",
|
||||
"ex": "#",
|
||||
"exs": "#",
|
||||
"iex": "#",
|
||||
"elm": "--",
|
||||
"emacs-lisp": ";",
|
||||
"elisp": ";",
|
||||
"emacs": ";",
|
||||
"erlang": "%",
|
||||
"erl": "%",
|
||||
"fsharp": "//",
|
||||
"f#": "//",
|
||||
"factor": "!",
|
||||
"fish": "#",
|
||||
"fishshell": "#",
|
||||
"forth": "\\",
|
||||
"fortran": "!",
|
||||
"f90": "!",
|
||||
"fortranfixed": "!",
|
||||
"go": "//",
|
||||
"golang": "//",
|
||||
"haskell": "--",
|
||||
"hs": "--",
|
||||
"inform6": "!",
|
||||
"i6": "!",
|
||||
"i6t": "!",
|
||||
"inform7": "!",
|
||||
"i7": "!",
|
||||
"j": "NB.",
|
||||
"java": "//",
|
||||
"jsp": "//",
|
||||
"javascript": "//",
|
||||
"js": "//",
|
||||
"julia": "#",
|
||||
"jl": "#",
|
||||
"jlcon": "#",
|
||||
"julia-repl": "#",
|
||||
"kotlin": "//",
|
||||
"lua": "--",
|
||||
"make": "#",
|
||||
"makefile": "#",
|
||||
"mf": "#",
|
||||
"bsdmake": "#",
|
||||
"matlab": "%",
|
||||
"matlabsession": "%",
|
||||
"monkey": "'",
|
||||
"mysql": "#",
|
||||
"newlisp": ";",
|
||||
"nimrod": "#",
|
||||
"nim": "#",
|
||||
"objective-c": "//",
|
||||
"objectivec": "//",
|
||||
"obj-c": "//",
|
||||
"objc": "//",
|
||||
"objective-c++": "//",
|
||||
"objectivec++": "//",
|
||||
"obj-c++": "//",
|
||||
"objc++": "//",
|
||||
"perl": "#",
|
||||
"pl": "#",
|
||||
"perl6": "#",
|
||||
"pl6": "#",
|
||||
"raku": "#",
|
||||
"php": "#",
|
||||
"php3": "#",
|
||||
"php4": "#",
|
||||
"php5": "#",
|
||||
"plpgsql": "--",
|
||||
"psql": "--",
|
||||
"postgresql-console": "--",
|
||||
"postgres-console": "--",
|
||||
"postgres-explain": "--",
|
||||
"postgresql": "--",
|
||||
"postgres": "--",
|
||||
"postscript": "%",
|
||||
"postscr": "%",
|
||||
"powershell": "#",
|
||||
"pwsh": "#",
|
||||
"posh": "#",
|
||||
"ps1": "#",
|
||||
"psm1": "#",
|
||||
"pwsh-session": "#",
|
||||
"ps1con": "#",
|
||||
"prolog": "%",
|
||||
"python": "#",
|
||||
"py": "#",
|
||||
"sage": "#",
|
||||
"python3": "#",
|
||||
"py3": "#",
|
||||
"python2": "#",
|
||||
"py2": "#",
|
||||
"py2tb": "#",
|
||||
"pycon": "#",
|
||||
"pytb": "#",
|
||||
"py3tb": "#",
|
||||
"py+ul4": "#",
|
||||
"qbasic": "REM",
|
||||
"basic": "REM",
|
||||
"ragel-ruby": "#",
|
||||
"ragel-rb": "#",
|
||||
"rebol": ";",
|
||||
"red": ";",
|
||||
"red/system": ";",
|
||||
"ruby": "#",
|
||||
"rb": "#",
|
||||
"duby": "#",
|
||||
"rbcon": "#",
|
||||
"irb": "#",
|
||||
"rust": "//",
|
||||
"rs": "//",
|
||||
"sass": "//",
|
||||
"scala": "//",
|
||||
"scheme": ";",
|
||||
"scm": ";",
|
||||
"sql": "--",
|
||||
"sql+jinja": "--",
|
||||
"sqlite3": "--",
|
||||
"swift": "//",
|
||||
"tex": "%",
|
||||
"latex": "%",
|
||||
"tsql": "--",
|
||||
"t-sql": "--",
|
||||
"vbscript": "'",
|
||||
"vhdl": "--",
|
||||
"wast": ";;",
|
||||
"wat": ";;",
|
||||
"yaml": "#",
|
||||
"yaml+jinja": "#",
|
||||
"salt": "#",
|
||||
"sls": "#",
|
||||
"zig": "//",
|
||||
}
|
54
src/textual_textarea/containers.py
Normal file
54
src/textual_textarea/containers.py
Normal file
|
@ -0,0 +1,54 @@
|
|||
from typing import Any, Union
|
||||
|
||||
from textual.containers import Container, ScrollableContainer
|
||||
from textual.widget import Widget
|
||||
|
||||
|
||||
class TextContainer(
|
||||
ScrollableContainer,
|
||||
inherit_bindings=False,
|
||||
can_focus=False,
|
||||
can_focus_children=True,
|
||||
):
|
||||
DEFAULT_CSS = """
|
||||
TextContainer {
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
layers: main overlay;
|
||||
}
|
||||
"""
|
||||
|
||||
def scroll_to(
|
||||
self, x: Union[float, None] = None, y: Union[float, None] = None, **_: Any
|
||||
) -> None:
|
||||
return super().scroll_to(x, y, animate=True, duration=0.01)
|
||||
|
||||
|
||||
class FooterContainer(
|
||||
Container,
|
||||
inherit_bindings=False,
|
||||
can_focus=False,
|
||||
can_focus_children=True,
|
||||
):
|
||||
DEFAULT_CSS = """
|
||||
FooterContainer {
|
||||
dock: bottom;
|
||||
height: auto;
|
||||
width: 100%
|
||||
}
|
||||
FooterContainer.hide {
|
||||
height: 0;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*children: Widget,
|
||||
name: Union[str, None] = None,
|
||||
id: Union[str, None] = None, # noqa: A002
|
||||
classes: Union[str, None] = None,
|
||||
disabled: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
*children, name=name, id=id, classes=classes, disabled=disabled
|
||||
)
|
75
src/textual_textarea/error_modal.py
Normal file
75
src/textual_textarea/error_modal.py
Normal file
|
@ -0,0 +1,75 @@
|
|||
from typing import Union
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Vertical, VerticalScroll
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
class ErrorModal(ModalScreen):
|
||||
DEFAULT_CSS = """
|
||||
ErrorModal {
|
||||
align: center middle;
|
||||
padding: 0;
|
||||
}
|
||||
#error_modal__outer {
|
||||
border: round $error;
|
||||
background: $background;
|
||||
margin: 5 10;
|
||||
padding: 1 2;
|
||||
max-width: 88;
|
||||
}
|
||||
|
||||
#error_modal__header {
|
||||
dock: top;
|
||||
color: $text-muted;
|
||||
margin: 0 0 1 0;
|
||||
padding: 0 1;
|
||||
}
|
||||
|
||||
#error_modal__inner {
|
||||
border: round $background;
|
||||
padding: 1 1 1 2;
|
||||
}
|
||||
|
||||
#error_modal__info {
|
||||
padding: 0 3 0 0;
|
||||
}
|
||||
|
||||
#error_modal__footer {
|
||||
dock: bottom;
|
||||
color: $text-muted;
|
||||
margin: 1 0 0 0;
|
||||
padding: 0 1;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
header: str,
|
||||
error: BaseException,
|
||||
name: Union[str, None] = None,
|
||||
id: Union[str, None] = None, # noqa: A002
|
||||
classes: Union[str, None] = None,
|
||||
) -> None:
|
||||
super().__init__(name, id, classes)
|
||||
self.title = title
|
||||
self.header = header
|
||||
self.error = error
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="error_modal__outer"):
|
||||
yield Static(self.header, id="error_modal__header")
|
||||
with Vertical(id="error_modal__inner"):
|
||||
with VerticalScroll():
|
||||
yield Static(str(self.error), id="error_modal__info")
|
||||
yield Static("Press any key to continue.", id="error_modal__footer")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
container = self.query_one("#error_modal__outer")
|
||||
container.border_title = self.title
|
||||
|
||||
def on_key(self) -> None:
|
||||
self.app.pop_screen()
|
||||
self.app.action_focus_next()
|
77
src/textual_textarea/find_input.py
Normal file
77
src/textual_textarea/find_input.py
Normal file
|
@ -0,0 +1,77 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual import on
|
||||
from textual.events import Blur, Key
|
||||
from textual.widgets import Input
|
||||
|
||||
from textual_textarea.cancellable_input import CancellableInput
|
||||
|
||||
|
||||
class FindInput(CancellableInput):
|
||||
def __init__(
|
||||
self,
|
||||
value: str = "",
|
||||
history: list[str] | None = None,
|
||||
classes: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
value=value,
|
||||
placeholder="Find; enter for next; ESC to close; ↑↓ for history",
|
||||
password=False,
|
||||
type="text",
|
||||
id="textarea__find_input",
|
||||
classes=classes,
|
||||
)
|
||||
self.history: list[str] = [] if history is None else history
|
||||
self.history_index: int | None = None
|
||||
|
||||
@on(Key)
|
||||
def handle_special_keys(self, event: Key) -> None:
|
||||
if event.key not in ("up", "down", "f3"):
|
||||
self.history_index = None
|
||||
return
|
||||
event.stop()
|
||||
event.prevent_default()
|
||||
if event.key == "down":
|
||||
self._handle_down()
|
||||
elif event.key == "up":
|
||||
self._handle_up()
|
||||
elif event.key == "f3":
|
||||
self.post_message(Input.Submitted(self, self.value))
|
||||
|
||||
@on(Blur)
|
||||
def handle_blur(self) -> None:
|
||||
if self.value and (not self.history or self.value != self.history[-1]):
|
||||
self.history.append(self.value)
|
||||
|
||||
def _handle_down(self) -> None:
|
||||
if self.history_index is None:
|
||||
self.checkpoint()
|
||||
self.value = ""
|
||||
elif self.history_index == -1:
|
||||
self.history_index = None
|
||||
self.value = ""
|
||||
else:
|
||||
self.history_index += 1
|
||||
self.value = self.history[self.history_index]
|
||||
self.action_end()
|
||||
|
||||
def checkpoint(self) -> bool:
|
||||
if self.value and (not self.history or self.value != self.history[-1]):
|
||||
self.history.append(self.value)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_up(self) -> None:
|
||||
if not self.history:
|
||||
if self.value:
|
||||
self.history.append(self.value)
|
||||
self.value = ""
|
||||
return
|
||||
|
||||
if self.history_index is None:
|
||||
self.history_index = -1 if self.checkpoint() else 0
|
||||
|
||||
self.history_index = max(-1 * len(self.history), self.history_index - 1)
|
||||
self.value = self.history[self.history_index]
|
||||
self.action_end()
|
60
src/textual_textarea/goto_input.py
Normal file
60
src/textual_textarea/goto_input.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from textual.validation import ValidationResult, Validator
|
||||
|
||||
from textual_textarea.cancellable_input import CancellableInput
|
||||
|
||||
|
||||
class GotoLineValidator(Validator):
|
||||
def __init__(
|
||||
self,
|
||||
max_line_number: int,
|
||||
min_line_number: int = 1,
|
||||
failure_description: str = "Not a valid line number.",
|
||||
) -> None:
|
||||
super().__init__(failure_description)
|
||||
self.max_line_number = max_line_number
|
||||
self.min_line_number = min_line_number
|
||||
|
||||
def validate(self, value: str) -> ValidationResult:
|
||||
try:
|
||||
lno = int(value)
|
||||
except (ValueError, TypeError):
|
||||
return self.failure("Not a valid line number.")
|
||||
|
||||
if lno < self.min_line_number:
|
||||
return self.failure(f"Line number must be >= {self.min_line_number}")
|
||||
elif lno > self.max_line_number:
|
||||
return self.failure(f"Line number must be <= {self.max_line_number}")
|
||||
|
||||
return self.success()
|
||||
|
||||
|
||||
class GotoLineInput(CancellableInput):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_line_number: int,
|
||||
id: str | None = None, # noqa: A002
|
||||
classes: str | None = None,
|
||||
current_line: int | None = None,
|
||||
min_line_number: int = 1,
|
||||
) -> None:
|
||||
current_line_text = (
|
||||
f"Current line: {current_line}. " if current_line is not None else ""
|
||||
)
|
||||
range_text = (
|
||||
f"Enter a line number between {min_line_number} and " f"{max_line_number}."
|
||||
)
|
||||
placeholder = f"{current_line_text}{range_text} ESC to cancel."
|
||||
super().__init__(
|
||||
"",
|
||||
placeholder=placeholder,
|
||||
type="integer",
|
||||
validators=GotoLineValidator(
|
||||
max_line_number=max_line_number, min_line_number=min_line_number
|
||||
),
|
||||
validate_on={"changed"},
|
||||
id=id,
|
||||
classes=classes,
|
||||
)
|
38
src/textual_textarea/messages.py
Normal file
38
src/textual_textarea/messages.py
Normal file
|
@ -0,0 +1,38 @@
|
|||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from textual.message import Message
|
||||
|
||||
|
||||
class TextAreaClipboardError(Message, bubble=True):
|
||||
"""
|
||||
Posted when textarea cannot access the system clipboard
|
||||
"""
|
||||
|
||||
def __init__(self, action: str) -> None:
|
||||
super().__init__()
|
||||
self.action = action
|
||||
|
||||
|
||||
class TextAreaThemeError(Message, bubble=True):
|
||||
"""
|
||||
Posted when textarea cannot instantiate a theme
|
||||
"""
|
||||
|
||||
def __init__(self, theme: str) -> None:
|
||||
super().__init__()
|
||||
self.theme = theme
|
||||
|
||||
|
||||
class TextAreaSaved(Message, bubble=True):
|
||||
"""
|
||||
Posted when the textarea saved a file successfully.
|
||||
"""
|
||||
|
||||
def __init__(self, path: Union[Path, str]) -> None:
|
||||
self.path = str(path)
|
||||
super().__init__()
|
||||
|
||||
|
||||
class TextAreaHideCompletionList(Message):
|
||||
pass
|
127
src/textual_textarea/path_input.py
Normal file
127
src/textual_textarea/path_input.py
Normal file
|
@ -0,0 +1,127 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from rich.highlighter import Highlighter
|
||||
from textual.binding import Binding
|
||||
from textual.suggester import Suggester
|
||||
from textual.validation import ValidationResult, Validator
|
||||
|
||||
from textual_textarea.cancellable_input import CancellableInput
|
||||
|
||||
|
||||
def path_completer(prefix: str) -> list[tuple[str, str]]:
|
||||
try:
|
||||
original = Path(prefix)
|
||||
p = original.expanduser()
|
||||
if p.is_dir():
|
||||
matches = list(p.iterdir())
|
||||
else:
|
||||
matches = list(p.parent.glob(f"{p.name}*"))
|
||||
if original != p and original.parts and original.parts[0] == "~":
|
||||
prompts = [str(Path("~") / m.relative_to(Path.home())) for m in matches]
|
||||
elif not original.is_absolute() and prefix.startswith("./"):
|
||||
prompts = [f"./{m}" for m in matches]
|
||||
else:
|
||||
prompts = [str(m) for m in matches]
|
||||
return [(p, p) for p in prompts]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
class PathSuggester(Suggester):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(use_cache=True, case_sensitive=True)
|
||||
|
||||
async def get_suggestion(self, value: str) -> str | None:
|
||||
matches = path_completer(value)
|
||||
if len(matches) == 1:
|
||||
return str(matches[0][0])
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
class PathValidator(Validator):
|
||||
def __init__(
|
||||
self,
|
||||
dir_okay: bool,
|
||||
file_okay: bool,
|
||||
must_exist: bool,
|
||||
failure_description: str = "Not a valid path.",
|
||||
) -> None:
|
||||
self.dir_okay = dir_okay
|
||||
self.file_okay = file_okay
|
||||
self.must_exist = must_exist
|
||||
super().__init__(failure_description)
|
||||
|
||||
def validate(self, value: str) -> ValidationResult:
|
||||
if self.dir_okay and self.file_okay and not self.must_exist:
|
||||
return self.success()
|
||||
try:
|
||||
p = Path(value).expanduser().resolve()
|
||||
except Exception:
|
||||
return self.failure("Not a valid path.")
|
||||
|
||||
try:
|
||||
st = p.stat()
|
||||
except FileNotFoundError:
|
||||
if self.must_exist:
|
||||
return self.failure("File or directory does not exist.")
|
||||
return self.success()
|
||||
|
||||
if not self.dir_okay and stat.S_ISDIR(st.st_mode):
|
||||
return self.failure("Path cannot be a directory.")
|
||||
elif not self.file_okay and stat.S_ISREG(st.st_mode):
|
||||
return self.failure("Path cannot be a regular file.")
|
||||
|
||||
return self.success()
|
||||
|
||||
|
||||
class PathInput(CancellableInput):
|
||||
BINDINGS = [
|
||||
Binding("tab", "complete", "Accept Completion", show=False),
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value: str | None = None,
|
||||
placeholder: str = "",
|
||||
highlighter: Highlighter | None = None,
|
||||
password: bool = False,
|
||||
*,
|
||||
name: str | None = None,
|
||||
id: str | None = None, # noqa: A002
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
dir_okay: bool = True,
|
||||
file_okay: bool = True,
|
||||
must_exist: bool = False,
|
||||
tab_advances_focus: bool = False,
|
||||
) -> None:
|
||||
self.tab_advances_focus = tab_advances_focus
|
||||
super().__init__(
|
||||
value,
|
||||
placeholder,
|
||||
highlighter,
|
||||
password,
|
||||
suggester=PathSuggester(),
|
||||
validators=PathValidator(dir_okay, file_okay, must_exist),
|
||||
name=name,
|
||||
id=id,
|
||||
classes=classes,
|
||||
disabled=disabled,
|
||||
)
|
||||
|
||||
def action_complete(self) -> None:
|
||||
if self._suggestion and self._suggestion != self.value:
|
||||
self.action_cursor_right()
|
||||
elif self.tab_advances_focus:
|
||||
self.app.action_focus_next()
|
||||
|
||||
def _toggle_cursor(self) -> None:
|
||||
"""Toggle visibility of cursor."""
|
||||
if self.app.is_headless:
|
||||
self._cursor_visible = True
|
||||
else:
|
||||
self._cursor_visible = not self._cursor_visible
|
0
src/textual_textarea/py.typed
Normal file
0
src/textual_textarea/py.typed
Normal file
1465
src/textual_textarea/text_editor.py
Normal file
1465
src/textual_textarea/text_editor.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue