textual-widgets 0.32.1__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.
- textual_widgets/__init__.py +110 -0
- textual_widgets/about_screen.py +346 -0
- textual_widgets/clearable_input.py +136 -0
- textual_widgets/clickable_links.py +117 -0
- textual_widgets/context_menu.py +280 -0
- textual_widgets/crash_guard.py +237 -0
- textual_widgets/date_picker.py +381 -0
- textual_widgets/disclaimer_screen.py +396 -0
- textual_widgets/hamburger_menu.py +458 -0
- textual_widgets/http_status_screen.py +408 -0
- textual_widgets/info_header.py +481 -0
- textual_widgets/keymap.py +567 -0
- textual_widgets/log_panel.py +326 -0
- textual_widgets/quotes/quotes.json +174 -0
- textual_widgets/search_history_dropdown.py +410 -0
- textual_widgets/settings_screen.py +380 -0
- textual_widgets/splitter.py +431 -0
- textual_widgets/status_bar.py +124 -0
- textual_widgets/storybook/__init__.py +9 -0
- textual_widgets/storybook/__main__.py +11 -0
- textual_widgets/storybook/app.py +256 -0
- textual_widgets/storybook/stories.py +1076 -0
- textual_widgets/terminal_image.py +267 -0
- textual_widgets/terminal_title.py +74 -0
- textual_widgets/testing.py +96 -0
- textual_widgets/text_input_screen.py +241 -0
- textual_widgets/url_input_screen.py +246 -0
- textual_widgets-0.32.1.dist-info/METADATA +596 -0
- textual_widgets-0.32.1.dist-info/RECORD +33 -0
- textual_widgets-0.32.1.dist-info/WHEEL +5 -0
- textual_widgets-0.32.1.dist-info/entry_points.txt +2 -0
- textual_widgets-0.32.1.dist-info/licenses/LICENSE +190 -0
- textual_widgets-0.32.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Wiederverwendbare Textual-Widgets."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.32.1"
|
|
4
|
+
__author__ = "Michael Blaess"
|
|
5
|
+
|
|
6
|
+
from textual_widgets.about_screen import AboutScreen, Quote, load_quotes
|
|
7
|
+
from textual_widgets.clearable_input import ClearableInput
|
|
8
|
+
from textual_widgets.clickable_links import ClickableLinksMixin
|
|
9
|
+
from textual_widgets.context_menu import ContextMenuItem, ContextMenuScreen
|
|
10
|
+
from textual_widgets.crash_guard import CrashGuard, ErrorScreen
|
|
11
|
+
from textual_widgets.date_picker import CalendarGrid, DatePicker, DatePickerScreen
|
|
12
|
+
from textual_widgets.disclaimer_screen import (
|
|
13
|
+
DISCLAIMER_VERSION,
|
|
14
|
+
DisclaimerScreen,
|
|
15
|
+
DisclaimerStore,
|
|
16
|
+
disclaimer_text,
|
|
17
|
+
)
|
|
18
|
+
from textual_widgets.hamburger_menu import HamburgerItem, HamburgerMenu
|
|
19
|
+
from textual_widgets.http_status_screen import HttpStatusScreen
|
|
20
|
+
from textual_widgets.info_header import InfoAction, InfoHeader, InfoItem
|
|
21
|
+
from textual_widgets.keymap import (
|
|
22
|
+
COMMON_FUNCTION_KEYS,
|
|
23
|
+
PROTECTED_ACTIONS,
|
|
24
|
+
VIM_NAVIGATION,
|
|
25
|
+
KeyBinding,
|
|
26
|
+
KeymapProblem,
|
|
27
|
+
KeymapStyle,
|
|
28
|
+
ResolvedKeymap,
|
|
29
|
+
default_style_for_platform,
|
|
30
|
+
find_collisions,
|
|
31
|
+
function_key_number,
|
|
32
|
+
parse_overrides,
|
|
33
|
+
resolve_keymap,
|
|
34
|
+
sort_for_footer,
|
|
35
|
+
vim_navigation_bindings,
|
|
36
|
+
)
|
|
37
|
+
from textual_widgets.log_panel import LogMessage, LogPanel, LogRouter
|
|
38
|
+
from textual_widgets.search_history_dropdown import (
|
|
39
|
+
SearchHistoryDropdown,
|
|
40
|
+
SearchInputWithHistory,
|
|
41
|
+
)
|
|
42
|
+
from textual_widgets.settings_screen import BaseSettingsScreen
|
|
43
|
+
from textual_widgets.splitter import HorizontalSplitter, VerticalSplitter
|
|
44
|
+
from textual_widgets.status_bar import StatusBar, StatusItem
|
|
45
|
+
from textual_widgets.terminal_image import (
|
|
46
|
+
TerminalImage,
|
|
47
|
+
als_halbbloecke,
|
|
48
|
+
erkenne_protokoll,
|
|
49
|
+
vorab_initialisieren,
|
|
50
|
+
)
|
|
51
|
+
from textual_widgets.terminal_title import reset_terminal_title, set_terminal_title
|
|
52
|
+
from textual_widgets.text_input_screen import TextInputScreen
|
|
53
|
+
from textual_widgets.url_input_screen import UrlInputScreen
|
|
54
|
+
|
|
55
|
+
__all__ = [
|
|
56
|
+
"AboutScreen",
|
|
57
|
+
"BaseSettingsScreen",
|
|
58
|
+
"CalendarGrid",
|
|
59
|
+
"ClearableInput",
|
|
60
|
+
"ClickableLinksMixin",
|
|
61
|
+
"ContextMenuItem",
|
|
62
|
+
"ContextMenuScreen",
|
|
63
|
+
"CrashGuard",
|
|
64
|
+
"COMMON_FUNCTION_KEYS",
|
|
65
|
+
"DatePicker",
|
|
66
|
+
"DISCLAIMER_VERSION",
|
|
67
|
+
"DatePickerScreen",
|
|
68
|
+
"DisclaimerScreen",
|
|
69
|
+
"DisclaimerStore",
|
|
70
|
+
"disclaimer_text",
|
|
71
|
+
"ErrorScreen",
|
|
72
|
+
"HamburgerItem",
|
|
73
|
+
"HamburgerMenu",
|
|
74
|
+
"HorizontalSplitter",
|
|
75
|
+
"HttpStatusScreen",
|
|
76
|
+
"InfoAction",
|
|
77
|
+
"InfoHeader",
|
|
78
|
+
"InfoItem",
|
|
79
|
+
"KeyBinding",
|
|
80
|
+
"KeymapProblem",
|
|
81
|
+
"KeymapStyle",
|
|
82
|
+
"LogMessage",
|
|
83
|
+
"LogPanel",
|
|
84
|
+
"LogRouter",
|
|
85
|
+
"Quote",
|
|
86
|
+
"PROTECTED_ACTIONS",
|
|
87
|
+
"ResolvedKeymap",
|
|
88
|
+
"SearchHistoryDropdown",
|
|
89
|
+
"SearchInputWithHistory",
|
|
90
|
+
"StatusBar",
|
|
91
|
+
"StatusItem",
|
|
92
|
+
"TerminalImage",
|
|
93
|
+
"VIM_NAVIGATION",
|
|
94
|
+
"als_halbbloecke",
|
|
95
|
+
"erkenne_protokoll",
|
|
96
|
+
"vorab_initialisieren",
|
|
97
|
+
"TextInputScreen",
|
|
98
|
+
"UrlInputScreen",
|
|
99
|
+
"VerticalSplitter",
|
|
100
|
+
"load_quotes",
|
|
101
|
+
"default_style_for_platform",
|
|
102
|
+
"find_collisions",
|
|
103
|
+
"reset_terminal_title",
|
|
104
|
+
"parse_overrides",
|
|
105
|
+
"function_key_number",
|
|
106
|
+
"resolve_keymap",
|
|
107
|
+
"sort_for_footer",
|
|
108
|
+
"vim_navigation_bindings",
|
|
109
|
+
"set_terminal_title",
|
|
110
|
+
]
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Standardisierter About-Dialog fuer Textual-Apps.
|
|
2
|
+
|
|
3
|
+
Public API:
|
|
4
|
+
- `Quote` — Dataclass fuer ein Zitat (text, author).
|
|
5
|
+
- `load_quotes(lang)` — laedt den mitgelieferten Zitatpool (de/en).
|
|
6
|
+
- `AboutScreen` — ModalScreen mit einheitlichem Aufbau:
|
|
7
|
+
|
|
8
|
+
Headline
|
|
9
|
+
version - Autor - Release-Datum
|
|
10
|
+
Beschreibung
|
|
11
|
+
--- (Trenner, passt sich automatisch der Dialogbreite an)
|
|
12
|
+
Zitat
|
|
13
|
+
URL (optional, anklickbar)
|
|
14
|
+
ESC = Close
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
from textual_widgets import AboutScreen
|
|
18
|
+
|
|
19
|
+
def action_show_about(self) -> None:
|
|
20
|
+
self.push_screen(AboutScreen(
|
|
21
|
+
app_name="my-tool",
|
|
22
|
+
version=__version__,
|
|
23
|
+
author=__author__,
|
|
24
|
+
release=__year__,
|
|
25
|
+
description="Beschreibung.\\nZweite Zeile.",
|
|
26
|
+
lang="de",
|
|
27
|
+
))
|
|
28
|
+
|
|
29
|
+
Das Zitat wird bei jedem Oeffnen zufaellig aus dem Pool gewaehlt. Apps koennen
|
|
30
|
+
mit `quote=` ein festes Zitat oder mit `quotes=` eine eigene Liste vorgeben.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import random
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from importlib import resources
|
|
39
|
+
|
|
40
|
+
from rich.text import Text
|
|
41
|
+
from textual.app import ComposeResult
|
|
42
|
+
from textual.binding import Binding
|
|
43
|
+
from textual.containers import Center, VerticalScroll
|
|
44
|
+
from textual.events import Click
|
|
45
|
+
from textual.screen import ModalScreen
|
|
46
|
+
from textual.widgets import Button, Rule, Static
|
|
47
|
+
|
|
48
|
+
# Default-Beschriftung des Schliessen-Buttons pro Sprache.
|
|
49
|
+
_FOOTER_TEXT = {
|
|
50
|
+
"de": "Schließen (ESC)",
|
|
51
|
+
"en": "Close (ESC)",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
# Breiten-Schranken des Dialogs (Inhaltsbreite + Border + Padding).
|
|
55
|
+
_MIN_WIDTH = 44
|
|
56
|
+
_MAX_WIDTH = 92
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class Quote:
|
|
61
|
+
"""Ein Zitat mit Urheber.
|
|
62
|
+
|
|
63
|
+
Attributes:
|
|
64
|
+
text:
|
|
65
|
+
Der Zitattext. Darf Zeilenumbrueche enthalten.
|
|
66
|
+
author:
|
|
67
|
+
Name des Urhebers (z.B. "Emily Dickinson").
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
text: str
|
|
71
|
+
author: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def load_quotes(lang: str = "en") -> list[Quote]:
|
|
75
|
+
"""Laedt den mitgelieferten Zitatpool fuer eine Sprache.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
lang:
|
|
79
|
+
Sprachkuerzel ('de' oder 'en'). Bei unbekannter Sprache wird
|
|
80
|
+
der englische Pool zurueckgegeben.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Liste der Zitate. Leer, wenn die quotes.json fehlt oder unlesbar ist.
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
raw = (resources.files("textual_widgets") / "quotes" / "quotes.json").read_text(encoding="utf-8")
|
|
87
|
+
data = json.loads(raw)
|
|
88
|
+
except Exception:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
pool = data.get(lang) or data.get("en") or []
|
|
92
|
+
return [
|
|
93
|
+
Quote(text=str(entry["text"]), author=str(entry["author"]))
|
|
94
|
+
for entry in pool
|
|
95
|
+
if "text" in entry and "author" in entry
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class AboutScreen(ModalScreen[None]):
|
|
100
|
+
"""Standardisierter About-Dialog als ModalScreen.
|
|
101
|
+
|
|
102
|
+
Schliessen ueber ESC oder Klick ausserhalb des Dialogs. Die Dialogbreite
|
|
103
|
+
wird beim Mount aus der laengsten Inhaltszeile berechnet, sodass der
|
|
104
|
+
Trenner exakt buendig sitzt und kein Whitespace entsteht.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
DEFAULT_CSS = """
|
|
108
|
+
AboutScreen {
|
|
109
|
+
align: center middle;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
AboutScreen > VerticalScroll {
|
|
113
|
+
height: auto;
|
|
114
|
+
max-height: 90%;
|
|
115
|
+
background: $surface;
|
|
116
|
+
border: thick $accent;
|
|
117
|
+
padding: 1 2;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
AboutScreen #about-title {
|
|
121
|
+
width: 1fr;
|
|
122
|
+
height: 3;
|
|
123
|
+
content-align: center middle;
|
|
124
|
+
text-style: bold;
|
|
125
|
+
background: $accent;
|
|
126
|
+
color: auto;
|
|
127
|
+
margin-bottom: 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
AboutScreen #about-meta {
|
|
131
|
+
width: 1fr;
|
|
132
|
+
text-align: center;
|
|
133
|
+
margin-bottom: 1;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
AboutScreen #about-description {
|
|
137
|
+
width: 1fr;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* Rule fuellt mit width: 1fr exakt die (explizit gesetzte) Dialogbreite */
|
|
141
|
+
AboutScreen Rule {
|
|
142
|
+
width: 1fr;
|
|
143
|
+
color: $text-muted;
|
|
144
|
+
margin: 1 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
AboutScreen #about-quote {
|
|
148
|
+
width: 1fr;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
AboutScreen #about-url, AboutScreen #about-homepage-url {
|
|
152
|
+
width: 1fr;
|
|
153
|
+
content-align: center middle;
|
|
154
|
+
color: $accent;
|
|
155
|
+
margin-top: 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/* Homepage steht direkt unter der Repo-URL - kein doppeltes margin. */
|
|
159
|
+
AboutScreen #about-homepage-url {
|
|
160
|
+
margin-top: 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
AboutScreen #about-footer {
|
|
164
|
+
width: 1fr;
|
|
165
|
+
height: auto;
|
|
166
|
+
margin-top: 1;
|
|
167
|
+
}
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
BINDINGS = [
|
|
171
|
+
Binding("escape", "close", "ESC"),
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
def __init__(
|
|
175
|
+
self,
|
|
176
|
+
*,
|
|
177
|
+
app_name: str,
|
|
178
|
+
version: str,
|
|
179
|
+
author: str,
|
|
180
|
+
release: str,
|
|
181
|
+
description: str,
|
|
182
|
+
lang: str = "en",
|
|
183
|
+
license: str | None = None,
|
|
184
|
+
quote: Quote | None = None,
|
|
185
|
+
quotes: list[Quote] | None = None,
|
|
186
|
+
url: str | None = None,
|
|
187
|
+
homepage_url: str | None = "https://www.michaelblaess.de/",
|
|
188
|
+
footer: str | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Erstellt den About-Dialog.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
app_name:
|
|
194
|
+
Name der Anwendung (Headline-Balken).
|
|
195
|
+
version:
|
|
196
|
+
Versionsnummer ohne fuehrendes 'v' (z.B. "1.2.0"). Der Dialog
|
|
197
|
+
stellt das 'v' selbst voran.
|
|
198
|
+
author:
|
|
199
|
+
Name des Autors.
|
|
200
|
+
release:
|
|
201
|
+
Release-Datum oder -Jahr (z.B. "2026").
|
|
202
|
+
description:
|
|
203
|
+
Beschreibungstext. Darf Zeilenumbrueche enthalten.
|
|
204
|
+
lang:
|
|
205
|
+
Sprachkuerzel ('de' oder 'en') fuer Footer und Zitatpool.
|
|
206
|
+
license:
|
|
207
|
+
Optionale Lizenzangabe (z.B. "Apache 2.0"). Wird als letztes
|
|
208
|
+
Element an die Meta-Zeile angehaengt.
|
|
209
|
+
quote:
|
|
210
|
+
Optionales festes Zitat. Wenn gesetzt, wird kein zufaelliges
|
|
211
|
+
Zitat aus dem Pool gewaehlt.
|
|
212
|
+
quotes:
|
|
213
|
+
Optionale eigene Zitatliste. Wird statt des mitgelieferten
|
|
214
|
+
Pools verwendet (zufaellige Auswahl).
|
|
215
|
+
url:
|
|
216
|
+
Optionale Projekt-/Repo-URL. Wird unter dem Zitat als
|
|
217
|
+
anklickbarer Link angezeigt (Klick oeffnet im Browser).
|
|
218
|
+
homepage_url:
|
|
219
|
+
Optionale zweite URL (Autor/Homepage), die direkt unter
|
|
220
|
+
``url`` als anklickbarer Link erscheint. Default zeigt auf
|
|
221
|
+
die Homepage des Autors; mit ``None`` ausblenden.
|
|
222
|
+
footer:
|
|
223
|
+
Optionaler Footer-Text. Default ist das sprachabhaengige
|
|
224
|
+
"ESC = Schliessen" / "ESC = Close".
|
|
225
|
+
"""
|
|
226
|
+
super().__init__()
|
|
227
|
+
self._app_name = app_name
|
|
228
|
+
self._version = version.lstrip("v")
|
|
229
|
+
self._author = author
|
|
230
|
+
self._release = release
|
|
231
|
+
self._description = description.rstrip("\n")
|
|
232
|
+
self._license = license
|
|
233
|
+
self._url = url
|
|
234
|
+
self._homepage_url = homepage_url
|
|
235
|
+
self._lang = lang if lang in _FOOTER_TEXT else "en"
|
|
236
|
+
self._footer = footer if footer is not None else _FOOTER_TEXT[self._lang]
|
|
237
|
+
|
|
238
|
+
# Zitat bestimmen: explizit > eigene Liste > mitgelieferter Pool
|
|
239
|
+
if quote is not None:
|
|
240
|
+
self._quote: Quote | None = quote
|
|
241
|
+
else:
|
|
242
|
+
pool = quotes if quotes else load_quotes(self._lang)
|
|
243
|
+
self._quote = random.choice(pool) if pool else None
|
|
244
|
+
|
|
245
|
+
def compose(self) -> ComposeResult:
|
|
246
|
+
"""Erstellt das Modal-Layout."""
|
|
247
|
+
with VerticalScroll(id="about-dialog"):
|
|
248
|
+
yield Static(self._app_name, id="about-title")
|
|
249
|
+
yield Static(self._build_meta(), id="about-meta")
|
|
250
|
+
yield Static(self._description, id="about-description")
|
|
251
|
+
if self._quote is not None:
|
|
252
|
+
yield Rule(line_style="solid")
|
|
253
|
+
yield Static(self._build_quote(self._quote), id="about-quote")
|
|
254
|
+
if self._url:
|
|
255
|
+
# Klickbar ohne CTRL + Hover-Highlight via Textual-Action-Markup.
|
|
256
|
+
yield Static(
|
|
257
|
+
f"[@click=screen.open_about_url('repo')][underline]{self._url}[/underline][/]",
|
|
258
|
+
id="about-url",
|
|
259
|
+
markup=True,
|
|
260
|
+
)
|
|
261
|
+
if self._homepage_url:
|
|
262
|
+
yield Static(
|
|
263
|
+
f"[@click=screen.open_about_url('homepage')][underline]{self._homepage_url}[/underline][/]",
|
|
264
|
+
id="about-homepage-url",
|
|
265
|
+
markup=True,
|
|
266
|
+
)
|
|
267
|
+
with Center(id="about-footer"):
|
|
268
|
+
yield Button(self._footer, variant="primary", id="about-close")
|
|
269
|
+
|
|
270
|
+
def on_mount(self) -> None:
|
|
271
|
+
"""Setzt die Dialogbreite anhand der laengsten Inhaltszeile.
|
|
272
|
+
|
|
273
|
+
Ohne explizite Breite blaeht ein `width: 1fr`-Kind (Rule) den
|
|
274
|
+
`width: auto`-Container bis zur Bildschirmbreite auf.
|
|
275
|
+
"""
|
|
276
|
+
self.query_one("#about-dialog").styles.width = self._dialog_width()
|
|
277
|
+
|
|
278
|
+
def _build_meta(self) -> Text:
|
|
279
|
+
"""Baut die Meta-Zeile 'version - Autor - Release - Lizenz'."""
|
|
280
|
+
text = Text()
|
|
281
|
+
text.append(f"v{self._version}", style="bold")
|
|
282
|
+
text.append(" · ", style="dim")
|
|
283
|
+
text.append(self._author, style="bold")
|
|
284
|
+
text.append(" · ", style="dim")
|
|
285
|
+
text.append(self._release, style="bold")
|
|
286
|
+
if self._license:
|
|
287
|
+
text.append(" · ", style="dim")
|
|
288
|
+
text.append(self._license, style="bold")
|
|
289
|
+
return text
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _build_quote(quote: Quote) -> Text:
|
|
293
|
+
"""Baut das Zitat samt Urheber als Rich Text."""
|
|
294
|
+
text = Text()
|
|
295
|
+
text.append(quote.text, style="italic")
|
|
296
|
+
text.append("\n\n")
|
|
297
|
+
text.append(f"- {quote.author}", style="bold")
|
|
298
|
+
return text
|
|
299
|
+
|
|
300
|
+
def action_open_about_url(self, target: str = "repo") -> None:
|
|
301
|
+
"""Oeffnet die zur AboutScreen gehoerige URL im Browser.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
target:
|
|
305
|
+
``"repo"`` fuer die Projekt-/Repo-URL (Default),
|
|
306
|
+
``"homepage"`` fuer die Autor-Homepage.
|
|
307
|
+
"""
|
|
308
|
+
import contextlib
|
|
309
|
+
import webbrowser
|
|
310
|
+
|
|
311
|
+
url = self._homepage_url if target == "homepage" else self._url
|
|
312
|
+
if not url:
|
|
313
|
+
return
|
|
314
|
+
with contextlib.suppress(Exception):
|
|
315
|
+
webbrowser.open(url)
|
|
316
|
+
|
|
317
|
+
def _dialog_width(self) -> int:
|
|
318
|
+
"""Berechnet die Dialogbreite aus der laengsten Inhaltszeile."""
|
|
319
|
+
lines: list[str] = [self._app_name, self._build_meta().plain]
|
|
320
|
+
lines += self._description.split("\n")
|
|
321
|
+
if self._quote is not None:
|
|
322
|
+
lines += self._quote.text.split("\n")
|
|
323
|
+
lines.append(f"- {self._quote.author}")
|
|
324
|
+
if self._url:
|
|
325
|
+
lines.append(self._url)
|
|
326
|
+
if self._homepage_url:
|
|
327
|
+
lines.append(self._homepage_url)
|
|
328
|
+
lines.append(self._footer)
|
|
329
|
+
|
|
330
|
+
content = max((len(line) for line in lines), default=0)
|
|
331
|
+
# + 2 Border (thick) + 4 Container-Padding (padding: 1 2)
|
|
332
|
+
return max(_MIN_WIDTH, min(_MAX_WIDTH, content + 6))
|
|
333
|
+
|
|
334
|
+
def on_click(self, event: Click) -> None:
|
|
335
|
+
"""Klick ausserhalb des Dialogs schliesst ihn."""
|
|
336
|
+
widget, _ = self.get_widget_at(event.screen_x, event.screen_y)
|
|
337
|
+
if widget is self:
|
|
338
|
+
self.dismiss(None)
|
|
339
|
+
|
|
340
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
341
|
+
"""Schliesst den Dialog beim Klick auf den Schliessen-Button."""
|
|
342
|
+
self.dismiss(None)
|
|
343
|
+
|
|
344
|
+
def action_close(self) -> None:
|
|
345
|
+
"""Schliesst den Dialog (ESC)."""
|
|
346
|
+
self.dismiss(None)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Eingabefeld mit einem Knopf zum Leeren.
|
|
2
|
+
|
|
3
|
+
Textuals ``Input`` hat dafuer nichts - es kennt nur die Methode ``clear()``.
|
|
4
|
+
Wer den Knopf will, baut ihn sonst in jeder Anwendung neu daneben.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from textual import on
|
|
12
|
+
from textual.app import ComposeResult
|
|
13
|
+
from textual.containers import Horizontal
|
|
14
|
+
from textual.message import Message
|
|
15
|
+
from textual.widgets import Button, Input
|
|
16
|
+
|
|
17
|
+
SYMBOL = "X"
|
|
18
|
+
"""Vorgabe-Zeichen auf dem Knopf.
|
|
19
|
+
|
|
20
|
+
Bewusst ein lateinisches X: das Multiplikationszeichen sieht schoener aus,
|
|
21
|
+
ist aber mehrdeutig (ruff RUF001) und in manchen Schriften zwei Zellen breit.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ClearableInput(Horizontal):
|
|
26
|
+
"""Ein ``Input`` mit Leeren-Knopf daneben.
|
|
27
|
+
|
|
28
|
+
Der Knopf leert das Feld selbst und setzt den Fokus zurueck - wer mehr
|
|
29
|
+
braucht, faengt ``ClearableInput.Cleared`` ab.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
DEFAULT_CSS = """
|
|
33
|
+
ClearableInput {
|
|
34
|
+
height: auto;
|
|
35
|
+
}
|
|
36
|
+
ClearableInput > Input {
|
|
37
|
+
width: 1fr;
|
|
38
|
+
}
|
|
39
|
+
ClearableInput > Button {
|
|
40
|
+
width: 5;
|
|
41
|
+
min-width: 5;
|
|
42
|
+
}
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
class Cleared(Message):
|
|
46
|
+
"""Das Feld wurde ueber den Knopf geleert."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, eingabe: ClearableInput) -> None:
|
|
49
|
+
super().__init__()
|
|
50
|
+
self.eingabe = eingabe
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def control(self) -> ClearableInput:
|
|
54
|
+
return self.eingabe
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
*,
|
|
59
|
+
placeholder: str = "",
|
|
60
|
+
value: str = "",
|
|
61
|
+
symbol: str = SYMBOL,
|
|
62
|
+
tooltip: str = "",
|
|
63
|
+
input_id: str = "clearable-input",
|
|
64
|
+
button_id: str = "clearable-clear",
|
|
65
|
+
**kwargs: Any,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""Legt Feld und Knopf an.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
placeholder: Platzhaltertext des Eingabefelds.
|
|
71
|
+
value: Anfangswert.
|
|
72
|
+
symbol: Beschriftung des Knopfs, etwa ``"X"`` oder ``"⌫"``.
|
|
73
|
+
tooltip: Hinweis am Knopf.
|
|
74
|
+
input_id: ID des inneren ``Input``.
|
|
75
|
+
button_id: ID des Knopfs.
|
|
76
|
+
"""
|
|
77
|
+
super().__init__(**kwargs)
|
|
78
|
+
self._placeholder = placeholder
|
|
79
|
+
self._value = value
|
|
80
|
+
self._symbol = symbol
|
|
81
|
+
self._tooltip = tooltip
|
|
82
|
+
self._input_id = input_id
|
|
83
|
+
self._button_id = button_id
|
|
84
|
+
|
|
85
|
+
def compose(self) -> ComposeResult:
|
|
86
|
+
yield Input(placeholder=self._placeholder, value=self._value, id=self._input_id)
|
|
87
|
+
knopf = Button(self._symbol, id=self._button_id)
|
|
88
|
+
# Der Knopf soll den Fokus nicht aus dem Feld ziehen.
|
|
89
|
+
knopf.can_focus = False
|
|
90
|
+
if self._tooltip:
|
|
91
|
+
knopf.tooltip = self._tooltip
|
|
92
|
+
yield knopf
|
|
93
|
+
|
|
94
|
+
# -- oeffentlich ----------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def input(self) -> Input:
|
|
98
|
+
"""Das innere Eingabefeld."""
|
|
99
|
+
return self.query_one(f"#{self._input_id}", Input)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def value(self) -> str:
|
|
103
|
+
return self.input.value
|
|
104
|
+
|
|
105
|
+
@value.setter
|
|
106
|
+
def value(self, text: str) -> None:
|
|
107
|
+
self.input.value = text
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def disabled_input(self) -> bool:
|
|
111
|
+
"""Ob Feld und Knopf gesperrt sind."""
|
|
112
|
+
return bool(self.input.disabled)
|
|
113
|
+
|
|
114
|
+
def set_disabled(self, gesperrt: bool) -> None:
|
|
115
|
+
"""Sperrt oder entsperrt Feld und Knopf gemeinsam."""
|
|
116
|
+
self.input.disabled = gesperrt
|
|
117
|
+
self.query_one(f"#{self._button_id}", Button).disabled = gesperrt
|
|
118
|
+
|
|
119
|
+
def clear(self) -> None:
|
|
120
|
+
"""Leert das Feld, ohne eine Nachricht zu senden."""
|
|
121
|
+
self.input.value = ""
|
|
122
|
+
|
|
123
|
+
def focus_input(self) -> None:
|
|
124
|
+
"""Setzt den Fokus ins Feld."""
|
|
125
|
+
self.input.focus()
|
|
126
|
+
|
|
127
|
+
# -- intern ---------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
@on(Button.Pressed)
|
|
130
|
+
def _geleert(self, ereignis: Button.Pressed) -> None:
|
|
131
|
+
if ereignis.button.id != self._button_id:
|
|
132
|
+
return
|
|
133
|
+
ereignis.stop()
|
|
134
|
+
self.clear()
|
|
135
|
+
self.focus_input()
|
|
136
|
+
self.post_message(self.Cleared(self))
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Clickable-Links-Mixin fuer Textual-Apps.
|
|
2
|
+
|
|
3
|
+
Standardisiert klickbare Links in jeder Textual-App: rohe URLs in Rich-Text
|
|
4
|
+
werden zu Textual-Action-Markup-Schnipseln umgeschrieben, das Textual ohne
|
|
5
|
+
CTRL-Klick aufnimmt und beim Maus-Hover automatisch hervorhebt.
|
|
6
|
+
|
|
7
|
+
Verwendung:
|
|
8
|
+
|
|
9
|
+
class MyApp(ClickableLinksMixin, App):
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
# Irgendwo im Code
|
|
13
|
+
self._log.write(self.linkify_urls("Sitemap geschrieben: https://example.com"))
|
|
14
|
+
self._panel.update(self.link_markup("Dokumentation", "https://docs.example.com"))
|
|
15
|
+
|
|
16
|
+
Beide Methoden produzieren ein Markup-Snippet vom Format
|
|
17
|
+
``[@click=app.open_link({id})]label[/]``. Beim Klick (oder Enter waehrend
|
|
18
|
+
Hover) ruft Textual ``action_open_link(id)`` auf, das die Mixin bereitstellt:
|
|
19
|
+
http(s)-URLs gehen ueber ``webbrowser.open``, lokale Pfade ueber das jeweilige
|
|
20
|
+
Standard-OS-Kommando.
|
|
21
|
+
|
|
22
|
+
Sonderzeichen in URLs/Pfaden sind unkritisch, weil die Targets in einer
|
|
23
|
+
internen Registry liegen und das Markup nur die ID enthaelt.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import contextlib
|
|
29
|
+
import os
|
|
30
|
+
import platform
|
|
31
|
+
import re
|
|
32
|
+
import subprocess
|
|
33
|
+
import webbrowser
|
|
34
|
+
from typing import Any
|
|
35
|
+
|
|
36
|
+
# http(s)-URL — vermeidet die Re-Linkifizierung von URLs, die bereits in einem
|
|
37
|
+
# ``[link …]``- oder ``[@click=…]``-Markup-Block stecken.
|
|
38
|
+
_URL_RE = re.compile(r"(?<!=)(?<!\])(?<!\")(https?://[^\s\[\]<>\"']+)")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ClickableLinksMixin:
|
|
42
|
+
"""Mixin: registriert Klick-Ziele und oeffnet sie auf Klick.
|
|
43
|
+
|
|
44
|
+
Verbraucht eine kleine integer-Counter-State, registriert URLs/Pfade
|
|
45
|
+
on demand und stellt die Aktion bereit, die Textual beim Klick aufruft.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
_link_registry: dict[int, str]
|
|
49
|
+
_link_counter: int
|
|
50
|
+
|
|
51
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
52
|
+
super().__init__(*args, **kwargs)
|
|
53
|
+
self._link_registry = {}
|
|
54
|
+
self._link_counter = 0
|
|
55
|
+
|
|
56
|
+
def link_markup(self, text: str, target: str) -> str:
|
|
57
|
+
"""Erzeugt Textual-Action-Markup fuer einen klickbaren Link.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
text:
|
|
61
|
+
Sichtbarer Linktext.
|
|
62
|
+
target:
|
|
63
|
+
URL (``http://`` / ``https://``) oder lokaler Dateipfad.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Markup-Snippet, das in beliebigen Rich-Text-Kontexten verwendet
|
|
67
|
+
werden kann. Bei leerem ``target`` wird ``text`` unveraendert
|
|
68
|
+
zurueckgegeben.
|
|
69
|
+
"""
|
|
70
|
+
if not target:
|
|
71
|
+
return text
|
|
72
|
+
self._link_counter += 1
|
|
73
|
+
link_id = self._link_counter
|
|
74
|
+
self._link_registry[link_id] = target
|
|
75
|
+
return f"[@click=app.open_link({link_id})]{text}[/]"
|
|
76
|
+
|
|
77
|
+
def linkify_urls(self, message: str) -> str:
|
|
78
|
+
"""Rewrites jede rohe ``http(s)``-URL in ``message`` zu klickbarem Markup.
|
|
79
|
+
|
|
80
|
+
URLs, die bereits in einem ``[link …]``- oder ``[@click=…]``-Markup
|
|
81
|
+
stecken, werden in Ruhe gelassen.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
message:
|
|
85
|
+
Beliebige Rich-Text-Nachricht.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Die Nachricht mit allen rohen URLs als klickbarem Markup.
|
|
89
|
+
"""
|
|
90
|
+
return _URL_RE.sub(lambda m: self.link_markup(m.group(1), m.group(1)), message)
|
|
91
|
+
|
|
92
|
+
def action_open_link(self, link_id: str) -> None:
|
|
93
|
+
"""Oeffnet das zur ID registrierte URL/Datei-Ziel im OS-Standard-Programm.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
link_id:
|
|
97
|
+
Die in ``link_markup`` vergebene ID — Textual reicht sie als
|
|
98
|
+
String aus dem Markup durch.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
key = int(link_id)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
return
|
|
104
|
+
target = self._link_registry.get(key, "")
|
|
105
|
+
if not target:
|
|
106
|
+
return
|
|
107
|
+
if target.startswith(("http://", "https://")):
|
|
108
|
+
with contextlib.suppress(Exception):
|
|
109
|
+
webbrowser.open(target)
|
|
110
|
+
return
|
|
111
|
+
with contextlib.suppress(Exception):
|
|
112
|
+
if platform.system() == "Windows":
|
|
113
|
+
os.startfile(target) # type: ignore[attr-defined]
|
|
114
|
+
elif platform.system() == "Darwin":
|
|
115
|
+
subprocess.Popen(["open", target])
|
|
116
|
+
else:
|
|
117
|
+
subprocess.Popen(["xdg-open", target])
|