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.
@@ -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])