batrachian-toad 0.5.22__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- batrachian_toad-0.5.22.dist-info/METADATA +197 -0
- batrachian_toad-0.5.22.dist-info/RECORD +120 -0
- batrachian_toad-0.5.22.dist-info/WHEEL +4 -0
- batrachian_toad-0.5.22.dist-info/entry_points.txt +2 -0
- batrachian_toad-0.5.22.dist-info/licenses/LICENSE +661 -0
- toad/__init__.py +46 -0
- toad/__main__.py +4 -0
- toad/_loop.py +86 -0
- toad/about.py +90 -0
- toad/acp/agent.py +671 -0
- toad/acp/api.py +47 -0
- toad/acp/encode_tool_call_id.py +12 -0
- toad/acp/messages.py +138 -0
- toad/acp/prompt.py +54 -0
- toad/acp/protocol.py +426 -0
- toad/agent.py +62 -0
- toad/agent_schema.py +70 -0
- toad/agents.py +45 -0
- toad/ansi/__init__.py +1 -0
- toad/ansi/_ansi.py +1612 -0
- toad/ansi/_ansi_colors.py +264 -0
- toad/ansi/_control_codes.py +37 -0
- toad/ansi/_keys.py +251 -0
- toad/ansi/_sgr_styles.py +64 -0
- toad/ansi/_stream_parser.py +418 -0
- toad/answer.py +22 -0
- toad/app.py +557 -0
- toad/atomic.py +37 -0
- toad/cli.py +257 -0
- toad/code_analyze.py +28 -0
- toad/complete.py +34 -0
- toad/constants.py +58 -0
- toad/conversation_markdown.py +19 -0
- toad/danger.py +371 -0
- toad/data/agents/ampcode.com.toml +51 -0
- toad/data/agents/augmentcode.com.toml +40 -0
- toad/data/agents/claude.com.toml +41 -0
- toad/data/agents/docker.com.toml +59 -0
- toad/data/agents/geminicli.com.toml +28 -0
- toad/data/agents/goose.ai.toml +51 -0
- toad/data/agents/inference.huggingface.co.toml +33 -0
- toad/data/agents/kimi.com.toml +35 -0
- toad/data/agents/openai.com.toml +53 -0
- toad/data/agents/opencode.ai.toml +61 -0
- toad/data/agents/openhands.dev.toml +44 -0
- toad/data/agents/stakpak.dev.toml +61 -0
- toad/data/agents/vibe.mistral.ai.toml +27 -0
- toad/data/agents/vtcode.dev.toml +62 -0
- toad/data/images/frog.png +0 -0
- toad/data/sounds/turn-over.wav +0 -0
- toad/db.py +5 -0
- toad/dec.py +332 -0
- toad/directory.py +234 -0
- toad/directory_watcher.py +96 -0
- toad/fuzzy.py +140 -0
- toad/gist.py +2 -0
- toad/history.py +138 -0
- toad/jsonrpc.py +576 -0
- toad/menus.py +14 -0
- toad/messages.py +74 -0
- toad/option_content.py +51 -0
- toad/os.py +0 -0
- toad/path_complete.py +145 -0
- toad/path_filter.py +124 -0
- toad/paths.py +71 -0
- toad/pill.py +23 -0
- toad/prompt/extract.py +19 -0
- toad/prompt/resource.py +68 -0
- toad/protocol.py +28 -0
- toad/screens/action_modal.py +94 -0
- toad/screens/agent_modal.py +172 -0
- toad/screens/command_edit_modal.py +58 -0
- toad/screens/main.py +192 -0
- toad/screens/permissions.py +390 -0
- toad/screens/permissions.tcss +72 -0
- toad/screens/settings.py +254 -0
- toad/screens/settings.tcss +101 -0
- toad/screens/store.py +476 -0
- toad/screens/store.tcss +261 -0
- toad/settings.py +354 -0
- toad/settings_schema.py +318 -0
- toad/shell.py +263 -0
- toad/shell_read.py +42 -0
- toad/slash_command.py +34 -0
- toad/toad.tcss +752 -0
- toad/version.py +80 -0
- toad/visuals/columns.py +273 -0
- toad/widgets/agent_response.py +79 -0
- toad/widgets/agent_thought.py +41 -0
- toad/widgets/command_pane.py +224 -0
- toad/widgets/condensed_path.py +93 -0
- toad/widgets/conversation.py +1626 -0
- toad/widgets/danger_warning.py +65 -0
- toad/widgets/diff_view.py +709 -0
- toad/widgets/flash.py +81 -0
- toad/widgets/future_text.py +126 -0
- toad/widgets/grid_select.py +223 -0
- toad/widgets/highlighted_textarea.py +180 -0
- toad/widgets/mandelbrot.py +294 -0
- toad/widgets/markdown_note.py +13 -0
- toad/widgets/menu.py +147 -0
- toad/widgets/non_selectable_label.py +5 -0
- toad/widgets/note.py +18 -0
- toad/widgets/path_search.py +381 -0
- toad/widgets/plan.py +180 -0
- toad/widgets/project_directory_tree.py +74 -0
- toad/widgets/prompt.py +741 -0
- toad/widgets/question.py +337 -0
- toad/widgets/shell_result.py +35 -0
- toad/widgets/shell_terminal.py +18 -0
- toad/widgets/side_bar.py +74 -0
- toad/widgets/slash_complete.py +211 -0
- toad/widgets/strike_text.py +66 -0
- toad/widgets/terminal.py +526 -0
- toad/widgets/terminal_tool.py +338 -0
- toad/widgets/throbber.py +90 -0
- toad/widgets/tool_call.py +303 -0
- toad/widgets/user_input.py +23 -0
- toad/widgets/version.py +5 -0
- toad/widgets/welcome.py +31 -0
toad/widgets/flash.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
|
|
3
|
+
from textual.content import Content
|
|
4
|
+
from textual.reactive import var
|
|
5
|
+
from textual.widgets import Static
|
|
6
|
+
from textual.timer import Timer
|
|
7
|
+
from textual import getters
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from toad.app import ToadApp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Flash(Static):
|
|
14
|
+
DEFAULT_CSS = """
|
|
15
|
+
Flash {
|
|
16
|
+
height: 1;
|
|
17
|
+
width: 1fr;
|
|
18
|
+
background: $success 10%;
|
|
19
|
+
color: $text-success;
|
|
20
|
+
text-align: center;
|
|
21
|
+
visibility: hidden;
|
|
22
|
+
text-wrap: nowrap;
|
|
23
|
+
text-overflow: ellipsis;
|
|
24
|
+
# overlay: screen;
|
|
25
|
+
# offset-y: -1;
|
|
26
|
+
&.-default {
|
|
27
|
+
background: $primary 10%;
|
|
28
|
+
color: $text-primary;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
&.-success {
|
|
32
|
+
background: $success 10%;
|
|
33
|
+
color: $text-success;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
&.-warning {
|
|
38
|
+
background: $warning 10%;
|
|
39
|
+
color: $text-warning;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
&.-error {
|
|
43
|
+
background: $error 10%;
|
|
44
|
+
color: $text-error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
app = getters.app(ToadApp)
|
|
49
|
+
flash_timer: var[Timer | None] = var(None)
|
|
50
|
+
|
|
51
|
+
def flash(
|
|
52
|
+
self,
|
|
53
|
+
content: str | Content,
|
|
54
|
+
*,
|
|
55
|
+
duration: float | None = None,
|
|
56
|
+
style: Literal["default", "success", "warning", "error"] = "default",
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Flash the content for a brief period.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
content: Content to show.
|
|
62
|
+
duration: Duration in seconds to show content.
|
|
63
|
+
style: A semantic style.
|
|
64
|
+
"""
|
|
65
|
+
if self.flash_timer is not None:
|
|
66
|
+
self.flash_timer.stop()
|
|
67
|
+
self.visible = False
|
|
68
|
+
|
|
69
|
+
def hide() -> None:
|
|
70
|
+
"""Hide the content after a while."""
|
|
71
|
+
self.visible = False
|
|
72
|
+
|
|
73
|
+
self.update(content)
|
|
74
|
+
self.remove_class("-default", "-success", "-warning", "-error", update=False)
|
|
75
|
+
self.add_class(f"-{style}")
|
|
76
|
+
self.visible = True
|
|
77
|
+
|
|
78
|
+
if duration is None:
|
|
79
|
+
duration = self.app.settings.get("ui.flash_duration", float)
|
|
80
|
+
|
|
81
|
+
self.flash_timer = self.set_timer(duration or 3, hide)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from math import ceil
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
from time import monotonic
|
|
6
|
+
|
|
7
|
+
from textual.color import Color
|
|
8
|
+
from textual.reactive import var
|
|
9
|
+
from textual.content import Content
|
|
10
|
+
from textual.style import Style
|
|
11
|
+
from textual.timer import Timer
|
|
12
|
+
from textual.widgets import Static
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FutureText(Static):
|
|
16
|
+
"""Text which appears one letter at time, like the movies."""
|
|
17
|
+
|
|
18
|
+
DEFAULT_CSS = """
|
|
19
|
+
FutureText {
|
|
20
|
+
width: auto;
|
|
21
|
+
height: 1;
|
|
22
|
+
text-wrap: nowrap;
|
|
23
|
+
text-align: center;
|
|
24
|
+
color: $primary;
|
|
25
|
+
&>.future-text--cursor {
|
|
26
|
+
color: $primary;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
ALLOW_SELECT = False
|
|
31
|
+
COMPONENT_CLASSES = {"future-text--cursor"}
|
|
32
|
+
|
|
33
|
+
BARS: ClassVar[list[str]] = ["▉", "▊", "▋", "▌", "▍", "▎", "▏", " "]
|
|
34
|
+
text_offset = var(0)
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
text_list: list[Content],
|
|
39
|
+
*,
|
|
40
|
+
speed: float = 16.0,
|
|
41
|
+
name: str | None = None,
|
|
42
|
+
id: str | None = None,
|
|
43
|
+
classes: str | None = None,
|
|
44
|
+
):
|
|
45
|
+
self.text_list = text_list
|
|
46
|
+
self.speed = speed
|
|
47
|
+
self.start_time = monotonic()
|
|
48
|
+
super().__init__(name=name, id=id, classes=classes)
|
|
49
|
+
self._update_timer: Timer | None = None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def text(self) -> Content:
|
|
53
|
+
return self.text_list[self.text_offset % len(self.text_list)]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def time(self) -> float:
|
|
57
|
+
return monotonic() - self.start_time
|
|
58
|
+
|
|
59
|
+
def on_mount(self) -> None:
|
|
60
|
+
self.start_time = monotonic()
|
|
61
|
+
self.set_interval(1 / 60, self._update_text)
|
|
62
|
+
|
|
63
|
+
def _update_text(self) -> None:
|
|
64
|
+
if not self.is_attached or not self.screen.is_active:
|
|
65
|
+
return
|
|
66
|
+
text = self.text + " "
|
|
67
|
+
speed_time = self.time * self.speed
|
|
68
|
+
progress, fractional_progress = divmod(speed_time, 1)
|
|
69
|
+
end = progress >= len(text)
|
|
70
|
+
cursor_progress = 0 if end else int(fractional_progress * 8)
|
|
71
|
+
text = text[: ceil(progress)]
|
|
72
|
+
|
|
73
|
+
bar_character = self.BARS[7 - cursor_progress]
|
|
74
|
+
|
|
75
|
+
cursor_styles = self.get_component_styles("future-text--cursor")
|
|
76
|
+
cursor_style = Style(foreground=cursor_styles.color)
|
|
77
|
+
reverse_cursor_style = cursor_style + Style(reverse=True)
|
|
78
|
+
|
|
79
|
+
# Fade in last character
|
|
80
|
+
fade_style = Style(
|
|
81
|
+
foreground=Color.blend(
|
|
82
|
+
cursor_styles.background, cursor_styles.color, ceil(fractional_progress)
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
fade_text = Content.assemble(
|
|
87
|
+
text[:-1],
|
|
88
|
+
((text[-1].plain if text else " "), fade_style),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if speed_time >= 1:
|
|
92
|
+
text = Content.assemble(
|
|
93
|
+
fade_text,
|
|
94
|
+
(bar_character, reverse_cursor_style),
|
|
95
|
+
(bar_character, cursor_style),
|
|
96
|
+
" " * (len(self.text) + 1 - len(fade_text)),
|
|
97
|
+
)
|
|
98
|
+
self.update(text, layout=False)
|
|
99
|
+
|
|
100
|
+
if progress > len(text) + 10 * 5:
|
|
101
|
+
self.text_offset += 1
|
|
102
|
+
self.start_time = monotonic()
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
if __name__ == "__main__":
|
|
106
|
+
from textual.app import App, ComposeResult
|
|
107
|
+
|
|
108
|
+
TEXT = [Content("Thinking..."), Content("Working hard..."), Content("Nearly there")]
|
|
109
|
+
|
|
110
|
+
class TextApp(App):
|
|
111
|
+
CSS = """
|
|
112
|
+
Screen {
|
|
113
|
+
padding: 2 4;
|
|
114
|
+
FutureText {
|
|
115
|
+
width: auto;
|
|
116
|
+
max-width: 1fr;
|
|
117
|
+
height: auto;
|
|
118
|
+
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def compose(self) -> ComposeResult:
|
|
124
|
+
yield FutureText(TEXT)
|
|
125
|
+
|
|
126
|
+
TextApp().run()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from textual import containers
|
|
4
|
+
from textual.binding import Binding
|
|
5
|
+
from textual import events
|
|
6
|
+
from textual.message import Message
|
|
7
|
+
from textual.reactive import reactive
|
|
8
|
+
from textual.layouts.grid import GridLayout
|
|
9
|
+
from textual.widget import Widget
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GridSelect(containers.ItemGrid, can_focus=True):
|
|
13
|
+
FOCUS_ON_CLICK = False
|
|
14
|
+
CURSOR_GROUP = Binding.Group("Select")
|
|
15
|
+
FOCUS_GROUP = Binding.Group("Focus")
|
|
16
|
+
BINDINGS = [
|
|
17
|
+
Binding("up", "cursor_up", "Cursor Up", group=CURSOR_GROUP),
|
|
18
|
+
Binding("down", "cursor_down", "Cursor Down", group=CURSOR_GROUP),
|
|
19
|
+
Binding("left", "cursor_left", "Cursor Left", group=CURSOR_GROUP),
|
|
20
|
+
Binding("right", "cursor_right", "Cursor Right", group=CURSOR_GROUP),
|
|
21
|
+
Binding("enter", "select", "Select"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
highlighted: reactive[int | None] = reactive(None)
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Selected(Message):
|
|
28
|
+
grid_select: "GridSelect"
|
|
29
|
+
selected_widget: Widget
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def control(self) -> Widget:
|
|
33
|
+
return self.grid_select
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class LeaveUp(Message):
|
|
37
|
+
grid_select: "GridSelect"
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class LeaveDown(Message):
|
|
41
|
+
grid_select: "GridSelect"
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
name: str | None = None,
|
|
46
|
+
id: str | None = None,
|
|
47
|
+
classes: str | None = None,
|
|
48
|
+
min_column_width: int = 30,
|
|
49
|
+
max_column_width: int | None = None,
|
|
50
|
+
):
|
|
51
|
+
super().__init__(
|
|
52
|
+
name=name,
|
|
53
|
+
id=id,
|
|
54
|
+
classes=classes,
|
|
55
|
+
min_column_width=min_column_width,
|
|
56
|
+
max_column_width=max_column_width,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def grid_size(self) -> tuple[int, int] | None:
|
|
61
|
+
assert isinstance(self.layout, GridLayout)
|
|
62
|
+
return self.layout.grid_size
|
|
63
|
+
|
|
64
|
+
def highlight_first(self) -> None:
|
|
65
|
+
self.highlighted = 0
|
|
66
|
+
|
|
67
|
+
def highlight_last(self) -> None:
|
|
68
|
+
if (grid_size := self.grid_size) is not None:
|
|
69
|
+
width, height = grid_size
|
|
70
|
+
|
|
71
|
+
if width == 1:
|
|
72
|
+
self.highlighted = len(self.children) - 1
|
|
73
|
+
else:
|
|
74
|
+
self.highlighted = (height - 1) * width
|
|
75
|
+
|
|
76
|
+
def on_focus(self):
|
|
77
|
+
if self.highlighted is None:
|
|
78
|
+
self.highlighted = 0
|
|
79
|
+
self.reveal_highlight()
|
|
80
|
+
|
|
81
|
+
def on_blur(self) -> None:
|
|
82
|
+
self.highlighted = None
|
|
83
|
+
|
|
84
|
+
def reveal_highlight(self):
|
|
85
|
+
if self.highlighted is None:
|
|
86
|
+
return
|
|
87
|
+
try:
|
|
88
|
+
highlighted_widget = self.children[self.highlighted]
|
|
89
|
+
except IndexError:
|
|
90
|
+
pass
|
|
91
|
+
else:
|
|
92
|
+
if not self.screen.can_view_entire(highlighted_widget):
|
|
93
|
+
self.screen.scroll_to_center(highlighted_widget, origin_visible=True)
|
|
94
|
+
|
|
95
|
+
def watch_highlighted(
|
|
96
|
+
self, old_highlighted: int | None, highlighted: int | None
|
|
97
|
+
) -> None:
|
|
98
|
+
if old_highlighted is not None:
|
|
99
|
+
try:
|
|
100
|
+
self.children[old_highlighted].remove_class("-highlight")
|
|
101
|
+
except IndexError:
|
|
102
|
+
pass
|
|
103
|
+
if highlighted is not None:
|
|
104
|
+
try:
|
|
105
|
+
highlighted_widget = self.children[highlighted]
|
|
106
|
+
highlighted_widget.add_class("-highlight")
|
|
107
|
+
except IndexError:
|
|
108
|
+
pass
|
|
109
|
+
self.reveal_highlight()
|
|
110
|
+
|
|
111
|
+
def validate_highlighted(self, highlighted: int | None) -> int | None:
|
|
112
|
+
if highlighted is None:
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
if not self.children:
|
|
116
|
+
return None
|
|
117
|
+
if highlighted < 0:
|
|
118
|
+
return 0
|
|
119
|
+
if highlighted >= len(self.children):
|
|
120
|
+
return len(self.children) - 1
|
|
121
|
+
return highlighted
|
|
122
|
+
|
|
123
|
+
def action_cursor_up(self):
|
|
124
|
+
if (grid_size := self.grid_size) is None:
|
|
125
|
+
self.post_message(self.LeaveUp(self))
|
|
126
|
+
return
|
|
127
|
+
if self.highlighted is None:
|
|
128
|
+
self.highlighted = 0
|
|
129
|
+
else:
|
|
130
|
+
width, _height = grid_size
|
|
131
|
+
if self.highlighted >= width:
|
|
132
|
+
self.highlighted -= width
|
|
133
|
+
else:
|
|
134
|
+
self.post_message(self.LeaveUp(self))
|
|
135
|
+
|
|
136
|
+
def action_cursor_down(self):
|
|
137
|
+
if (grid_size := self.grid_size) is None:
|
|
138
|
+
self.post_message(self.LeaveDown(self))
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
if self.highlighted is None:
|
|
142
|
+
self.highlighted = 0
|
|
143
|
+
else:
|
|
144
|
+
width, height = grid_size
|
|
145
|
+
if self.highlighted + width < len(self.children):
|
|
146
|
+
self.highlighted += width
|
|
147
|
+
else:
|
|
148
|
+
self.post_message(self.LeaveDown(self))
|
|
149
|
+
|
|
150
|
+
def action_cursor_left(self):
|
|
151
|
+
if self.highlighted is None:
|
|
152
|
+
self.highlighted = 0
|
|
153
|
+
else:
|
|
154
|
+
self.highlighted -= 1
|
|
155
|
+
|
|
156
|
+
def action_cursor_right(self):
|
|
157
|
+
if self.highlighted is None:
|
|
158
|
+
self.highlighted = 0
|
|
159
|
+
else:
|
|
160
|
+
self.highlighted += 1
|
|
161
|
+
|
|
162
|
+
def on_click(self, event: events.Click) -> None:
|
|
163
|
+
if event.widget is None:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
highlighted_widget: Widget | None = None
|
|
167
|
+
if self.highlighted is not None:
|
|
168
|
+
try:
|
|
169
|
+
highlighted_widget = self.children[self.highlighted]
|
|
170
|
+
except IndexError:
|
|
171
|
+
pass
|
|
172
|
+
for widget in event.widget.ancestors_with_self:
|
|
173
|
+
if widget in self.children:
|
|
174
|
+
if highlighted_widget is not None and highlighted_widget is widget:
|
|
175
|
+
self.action_select()
|
|
176
|
+
else:
|
|
177
|
+
self.highlighted = self.children.index(widget)
|
|
178
|
+
break
|
|
179
|
+
self.focus()
|
|
180
|
+
|
|
181
|
+
def action_select(self):
|
|
182
|
+
if self.highlighted is not None:
|
|
183
|
+
try:
|
|
184
|
+
highlighted_widget = self.children[self.highlighted]
|
|
185
|
+
except IndexError:
|
|
186
|
+
pass
|
|
187
|
+
else:
|
|
188
|
+
self.post_message(self.Selected(self, highlighted_widget))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
if __name__ == "__main__":
|
|
192
|
+
from textual.app import App, ComposeResult
|
|
193
|
+
from textual import widgets
|
|
194
|
+
|
|
195
|
+
class GridApp(App):
|
|
196
|
+
CSS = """
|
|
197
|
+
.grid-item {
|
|
198
|
+
width: 1fr;
|
|
199
|
+
padding: 0 1;
|
|
200
|
+
# background: blue 20%;
|
|
201
|
+
border: blank;
|
|
202
|
+
|
|
203
|
+
&:hover {
|
|
204
|
+
background: $panel;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
&.-highlight {
|
|
208
|
+
border: tall $primary;
|
|
209
|
+
background: $panel;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def compose(self) -> ComposeResult:
|
|
215
|
+
yield widgets.Footer()
|
|
216
|
+
with GridSelect():
|
|
217
|
+
for n in range(50):
|
|
218
|
+
yield widgets.Label(
|
|
219
|
+
f"#{n} Where there is a Will, there is a Way!",
|
|
220
|
+
classes="grid-item",
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
GridApp().run()
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
import re2 as re
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from rich.text import Text
|
|
7
|
+
|
|
8
|
+
from textual import on
|
|
9
|
+
from textual.reactive import reactive
|
|
10
|
+
from textual.content import Content
|
|
11
|
+
from textual.highlight import highlight, HighlightTheme, TokenType
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
from textual.widgets import TextArea
|
|
14
|
+
from textual.widgets.text_area import Selection
|
|
15
|
+
|
|
16
|
+
from pygments.token import Token
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
RE_MATCH_FILE_PROMPT = re.compile(r"(@\S+)|@\"(.*)\"")
|
|
20
|
+
RE_SLASH_COMMAND = re.compile(r"(\/\S*)(\W.*)?$")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TextualHighlightTheme(HighlightTheme):
|
|
24
|
+
"""Contains the style definition for user with the highlight method."""
|
|
25
|
+
|
|
26
|
+
STYLES: dict[TokenType, str] = {
|
|
27
|
+
Token.Comment: "$text 60%",
|
|
28
|
+
Token.Error: "$text-error on $error-muted",
|
|
29
|
+
Token.Generic.Strong: "bold",
|
|
30
|
+
Token.Generic.Emph: "italic",
|
|
31
|
+
Token.Generic.Error: "$text-error on $error-muted",
|
|
32
|
+
Token.Generic.Heading: "$text-primary underline",
|
|
33
|
+
Token.Generic.Subheading: "$text-primary",
|
|
34
|
+
Token.Keyword: "$text-accent",
|
|
35
|
+
Token.Keyword.Constant: "bold $text-success 80%",
|
|
36
|
+
Token.Keyword.Namespace: "$text-error",
|
|
37
|
+
Token.Keyword.Type: "bold",
|
|
38
|
+
Token.Literal.Number: "$text-warning",
|
|
39
|
+
Token.Literal.String.Backtick: "$text 60%",
|
|
40
|
+
Token.Literal.String: "$text-success 90%",
|
|
41
|
+
Token.Literal.String.Doc: "$text-success 80% italic",
|
|
42
|
+
Token.Literal.String.Double: "$text-success 90%",
|
|
43
|
+
Token.Name: "$text-primary",
|
|
44
|
+
Token.Name.Attribute: "$text-warning",
|
|
45
|
+
Token.Name.Builtin: "$text-accent",
|
|
46
|
+
Token.Name.Builtin.Pseudo: "italic",
|
|
47
|
+
Token.Name.Class: "$text-warning bold",
|
|
48
|
+
Token.Name.Constant: "$text-error",
|
|
49
|
+
Token.Name.Decorator: "$text-primary bold",
|
|
50
|
+
Token.Name.Entity: "$text",
|
|
51
|
+
Token.Name.Function: "$text-warning underline",
|
|
52
|
+
Token.Name.Function.Magic: "$text-warning underline",
|
|
53
|
+
Token.Name.Tag: "$text-primary bold",
|
|
54
|
+
Token.Name.Variable: "$text-secondary",
|
|
55
|
+
Token.Number: "$text-warning",
|
|
56
|
+
Token.Operator: "bold",
|
|
57
|
+
Token.Operator.Word: "bold $text-error",
|
|
58
|
+
Token.String: "$text-success",
|
|
59
|
+
Token.Whitespace: "",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class HighlightedTextArea(TextArea):
|
|
64
|
+
highlight_language = reactive("markdown")
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class CursorMove(Message):
|
|
68
|
+
selection: Selection
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
text: str = "",
|
|
73
|
+
*,
|
|
74
|
+
name: str | None = None,
|
|
75
|
+
id: str | None = None,
|
|
76
|
+
classes: str | None = None,
|
|
77
|
+
disabled: bool = False,
|
|
78
|
+
placeholder: str | Content = "",
|
|
79
|
+
):
|
|
80
|
+
self._text_cache: dict[int, Text] = {}
|
|
81
|
+
self._highlight_lines: list[Content] | None = None
|
|
82
|
+
super().__init__(
|
|
83
|
+
text,
|
|
84
|
+
name=name,
|
|
85
|
+
id=id,
|
|
86
|
+
classes=classes,
|
|
87
|
+
disabled=disabled,
|
|
88
|
+
highlight_cursor_line=False,
|
|
89
|
+
placeholder=placeholder,
|
|
90
|
+
)
|
|
91
|
+
self.compact = True
|
|
92
|
+
|
|
93
|
+
def _clear_caches(self) -> None:
|
|
94
|
+
self._highlight_lines = None
|
|
95
|
+
self._text_cache.clear()
|
|
96
|
+
|
|
97
|
+
def notify_style_update(self) -> None:
|
|
98
|
+
self._clear_caches()
|
|
99
|
+
return super().notify_style_update()
|
|
100
|
+
|
|
101
|
+
def _watch_selection(
|
|
102
|
+
self, previous_selection: Selection, selection: Selection
|
|
103
|
+
) -> None:
|
|
104
|
+
self.post_message(self.CursorMove(selection))
|
|
105
|
+
super()._watch_selection(previous_selection, selection)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def highlight_lines(self) -> Sequence[Content]:
|
|
109
|
+
if self._highlight_lines is None:
|
|
110
|
+
text = self.text
|
|
111
|
+
if text.startswith("/") and "\n" not in text:
|
|
112
|
+
content = self.highlight_slash_command(text)
|
|
113
|
+
self._highlight_lines = [content]
|
|
114
|
+
return self._highlight_lines
|
|
115
|
+
|
|
116
|
+
language = self.highlight_language
|
|
117
|
+
if language == "markdown":
|
|
118
|
+
content = self.highlight_markdown(text)
|
|
119
|
+
content_lines = content.split("\n", allow_blank=True)[:-1]
|
|
120
|
+
self._highlight_lines = content_lines
|
|
121
|
+
elif language == "shell":
|
|
122
|
+
content = self.highlight_shell(text)
|
|
123
|
+
content_lines = content.split("\n", allow_blank=True)
|
|
124
|
+
self._highlight_lines = content_lines
|
|
125
|
+
else:
|
|
126
|
+
raise ValueError("highlight_language must be `markdown` or `shell`")
|
|
127
|
+
return self._highlight_lines
|
|
128
|
+
|
|
129
|
+
def highlight_slash_command(self, text: str) -> Content:
|
|
130
|
+
return Content.styled(text, "$text-success")
|
|
131
|
+
|
|
132
|
+
def highlight_markdown(self, text: str) -> Content:
|
|
133
|
+
"""Highlight markdown content.
|
|
134
|
+
|
|
135
|
+
Args:
|
|
136
|
+
text: Text containing Markdown.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
Highlighted content.
|
|
140
|
+
"""
|
|
141
|
+
content = highlight(
|
|
142
|
+
text + "\n```",
|
|
143
|
+
language="markdown",
|
|
144
|
+
theme=TextualHighlightTheme,
|
|
145
|
+
)
|
|
146
|
+
content = content.highlight_regex(RE_MATCH_FILE_PROMPT, style="$primary")
|
|
147
|
+
return content
|
|
148
|
+
|
|
149
|
+
def highlight_shell(self, text: str) -> Content:
|
|
150
|
+
"""Highlight text with a bash shell command.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
text: Text containing shell command.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Highlighted content.
|
|
157
|
+
"""
|
|
158
|
+
content = highlight(text, language="sh")
|
|
159
|
+
return content
|
|
160
|
+
|
|
161
|
+
@on(TextArea.Changed)
|
|
162
|
+
def _on_changed(self) -> None:
|
|
163
|
+
self._highlight_lines = None
|
|
164
|
+
self._text_cache.clear()
|
|
165
|
+
|
|
166
|
+
def get_line(self, line_index: int) -> Text:
|
|
167
|
+
if (cached_line := self._text_cache.get(line_index)) is not None:
|
|
168
|
+
return cached_line.copy()
|
|
169
|
+
try:
|
|
170
|
+
line = self.highlight_lines[line_index]
|
|
171
|
+
except IndexError:
|
|
172
|
+
return Text("", end="", no_wrap=True)
|
|
173
|
+
rendered_line = list(line.render_segments(self.visual_style))
|
|
174
|
+
text = Text.assemble(
|
|
175
|
+
*[(text, style) for text, style, _ in rendered_line],
|
|
176
|
+
end="",
|
|
177
|
+
no_wrap=True,
|
|
178
|
+
)
|
|
179
|
+
self._text_cache[line_index] = text.copy()
|
|
180
|
+
return text
|