termisations 0.1.0__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.
Files changed (69) hide show
  1. termisations/__init__.py +21 -0
  2. termisations/__main__.py +12 -0
  3. termisations/app.py +1565 -0
  4. termisations/app.tcss +183 -0
  5. termisations/cli.py +1101 -0
  6. termisations/core/__init__.py +7 -0
  7. termisations/core/colors.py +74 -0
  8. termisations/core/commands.py +1508 -0
  9. termisations/core/events.py +573 -0
  10. termisations/core/i18n.py +189 -0
  11. termisations/core/inputlog.py +285 -0
  12. termisations/core/metrics.py +223 -0
  13. termisations/core/models.py +498 -0
  14. termisations/core/paths.py +214 -0
  15. termisations/core/profile.py +422 -0
  16. termisations/core/redact.py +530 -0
  17. termisations/core/router.py +820 -0
  18. termisations/core/session.py +59 -0
  19. termisations/core/storage.py +639 -0
  20. termisations/core/trace.py +182 -0
  21. termisations/layouts.py +13 -0
  22. termisations/locale/ru/app.toml +90 -0
  23. termisations/locale/ru/cli.toml +79 -0
  24. termisations/locale/ru/core.commands.toml +109 -0
  25. termisations/locale/ru/core.events.toml +6 -0
  26. termisations/locale/ru/core.inputlog.toml +6 -0
  27. termisations/locale/ru/core.metrics.toml +5 -0
  28. termisations/locale/ru/core.models.toml +9 -0
  29. termisations/locale/ru/core.paths.toml +7 -0
  30. termisations/locale/ru/core.profile.toml +22 -0
  31. termisations/locale/ru/core.redact.toml +12 -0
  32. termisations/locale/ru/core.router.toml +17 -0
  33. termisations/locale/ru/core.storage.toml +5 -0
  34. termisations/locale/ru/core.trace.toml +7 -0
  35. termisations/locale/ru/mock.toml +164 -0
  36. termisations/locale/ru/protocol.account.toml +15 -0
  37. termisations/locale/ru/protocol.caps.toml +4 -0
  38. termisations/locale/ru/protocol.omemo.toml +6 -0
  39. termisations/locale/ru/protocol.omemo_storage.toml +7 -0
  40. termisations/locale/ru/protocol.sasl2.plugin.toml +12 -0
  41. termisations/locale/ru/protocol.session.toml +208 -0
  42. termisations/locale/ru/ui.chat.toml +16 -0
  43. termisations/locale/ru/ui.prompt.toml +22 -0
  44. termisations/locale/ru/ui.xmllog.toml +22 -0
  45. termisations/locale/ru/xeps.toml +11 -0
  46. termisations/mock.py +3682 -0
  47. termisations/protocol/__init__.py +6 -0
  48. termisations/protocol/account.py +193 -0
  49. termisations/protocol/caps.py +192 -0
  50. termisations/protocol/mam.py +159 -0
  51. termisations/protocol/omemo.py +164 -0
  52. termisations/protocol/omemo_storage.py +82 -0
  53. termisations/protocol/sasl2/__init__.py +17 -0
  54. termisations/protocol/sasl2/plugin.py +268 -0
  55. termisations/protocol/sasl2/stanza.py +231 -0
  56. termisations/protocol/session.py +3501 -0
  57. termisations/protocol/tracing.py +250 -0
  58. termisations/protocol/transport.py +175 -0
  59. termisations/ui/__init__.py +9 -0
  60. termisations/ui/chat.py +1213 -0
  61. termisations/ui/prompt.py +920 -0
  62. termisations/ui/statusbar.py +530 -0
  63. termisations/ui/xmllog.py +1275 -0
  64. termisations/xeps.py +564 -0
  65. termisations-0.1.0.dist-info/METADATA +698 -0
  66. termisations-0.1.0.dist-info/RECORD +69 -0
  67. termisations-0.1.0.dist-info/WHEEL +4 -0
  68. termisations-0.1.0.dist-info/entry_points.txt +2 -0
  69. termisations-0.1.0.dist-info/licenses/LICENSE +661 -0
termisations/app.py ADDED
@@ -0,0 +1,1565 @@
1
+ """Каркас приложения: раскладка, режимы, клавиши и соединение слоев.
2
+
3
+ Здесь и в ``cli.py`` слои сходятся вместе: создается шина, к ней подключается
4
+ источник событий, а панели подписываются на нужные им типы событий сами.
5
+ Приложение не обращается к сети и не разбирает протокол: любая введенная строка
6
+ уходит командой в шину, а исполняет ее сессия.
7
+
8
+ Переключение раскладки сделано сменой CSS-классов на контейнере ``#body``, а не
9
+ пересборкой дерева виджетов. Пересборка отрисовывает панели заново, дает заметный
10
+ кадр с пустым экраном и теряет позицию прокрутки, а критерий приемки требует
11
+ отсутствия артефактов перерисовки.
12
+ """
13
+
14
+ import os
15
+ import time
16
+ from collections import deque
17
+ from collections.abc import Callable, Mapping, Sequence
18
+ from dataclasses import dataclass, replace
19
+ from functools import partial
20
+ from pathlib import Path
21
+ from types import MappingProxyType
22
+ from typing import ClassVar, Final, TypeVar
23
+
24
+ from rich.text import Text
25
+ from textual import on
26
+ from textual.app import App, ComposeResult
27
+ from textual.binding import Binding, BindingType
28
+ from textual.command import CommandPalette, DiscoveryHit, Hit, Hits, Provider
29
+ from textual.containers import Container, Vertical
30
+ from textual.css.query import NoMatches
31
+ from textual.events import Resize
32
+ from textual.reactive import reactive
33
+ from textual.screen import ModalScreen
34
+ from textual.widget import Widget
35
+ from textual.widgets import Input, Static
36
+
37
+ from termisations import xeps
38
+ from termisations.core import commands, i18n
39
+ from termisations.core.events import (
40
+ ActiveConversationChanged,
41
+ ConversationsUpdated,
42
+ EventBus,
43
+ NoticeLevel,
44
+ OccupantsUpdated,
45
+ OpenConversation,
46
+ RosterUpdated,
47
+ RunCommandLine,
48
+ SendRawXml,
49
+ SetChatState,
50
+ SetUnsafeXml,
51
+ SetXmlFilter,
52
+ SetXmlMode,
53
+ StanzaLogged,
54
+ StateUpdated,
55
+ Subscription,
56
+ UnsafeModeChanged,
57
+ XmlLogModeChanged,
58
+ XmlMode,
59
+ )
60
+ from termisations.core.i18n import N_, _
61
+ from termisations.core.inputlog import InputLog
62
+ from termisations.core.models import (
63
+ MUC_MARK,
64
+ PRESENCE_LABELS,
65
+ PRESENCE_MARKS,
66
+ ClientState,
67
+ Conversation,
68
+ Direction,
69
+ PresenceShow,
70
+ RawStanza,
71
+ RosterItem,
72
+ format_latency,
73
+ humanize_bytes,
74
+ humanize_duration,
75
+ stage_label,
76
+ transport_label,
77
+ )
78
+ from termisations.core.redact import UNSAFE_WARNING, redact, redaction_summary
79
+ from termisations.core.session import Session
80
+ from termisations.layouts import LAYOUT_MODES
81
+ from termisations.ui.chat import ChatPanel
82
+ from termisations.ui.prompt import PromptInput
83
+ from termisations.ui.statusbar import StatusBar
84
+ from termisations.ui.xmllog import XmlLogPanel
85
+
86
+ __all__ = [
87
+ "QuitConfirmScreen",
88
+ "RosterEntry",
89
+ "RosterProvider",
90
+ "SlashCommandProvider",
91
+ "TermisationsApp",
92
+ ]
93
+
94
+ # Раскладка ЙЦУКЕН: какая русская буква стоит на клавише с латинской. Терминал
95
+ # с расширенным протоколом клавиатуры передает символ вместе с модификатором, а
96
+ # не управляющий код, поэтому при русской раскладке приходит ctrl+с, а не
97
+ # ctrl+c, и ни одно сочетание приложения не срабатывает.
98
+ _CYRILLIC_TWINS: Final[Mapping[str, str]] = MappingProxyType(
99
+ {
100
+ "q": "й",
101
+ "w": "ц",
102
+ "e": "у",
103
+ "r": "к",
104
+ "t": "е",
105
+ "y": "н",
106
+ "u": "г",
107
+ "i": "ш",
108
+ "o": "щ",
109
+ "p": "з",
110
+ "a": "ф",
111
+ "s": "ы",
112
+ "d": "в",
113
+ "f": "а",
114
+ "g": "п",
115
+ "h": "р",
116
+ "j": "о",
117
+ "k": "л",
118
+ "l": "д",
119
+ "z": "я",
120
+ "x": "ч",
121
+ "c": "с",
122
+ "v": "м",
123
+ "b": "и",
124
+ "n": "т",
125
+ "m": "ь",
126
+ }
127
+ )
128
+
129
+
130
+ def with_cyrillic(bindings: Sequence[BindingType]) -> list[BindingType]:
131
+ """Дополнить привязки их кириллическими двойниками.
132
+
133
+ Пишется один раз списком, а не руками у каждой клавиши: пропущенный
134
+ двойник обнаруживается не сборкой, а пользователем на русской раскладке.
135
+ Двойники скрыты из подсказки - в ней и без них тесно.
136
+ """
137
+ extra: list[BindingType] = []
138
+ for binding in bindings:
139
+ if not isinstance(binding, Binding):
140
+ continue
141
+ prefix, _plus, letter = binding.key.rpartition("+")
142
+ twin = _CYRILLIC_TWINS.get(letter)
143
+ if not prefix or twin is None:
144
+ continue
145
+ # Верхний регистр нужен из-за Caps Lock: терминал шлет символ как есть,
146
+ # и с включенным Caps приходит заглавная буква.
147
+ for key in (twin, twin.upper()):
148
+ extra.append(replace(binding, key=f"{prefix}+{key}", show=False))
149
+ return [*bindings, *extra]
150
+
151
+
152
+ # Пороги адаптивной раскладки из раздела 4.1 ТЗ.
153
+ WIDE_WIDTH: Final = 160
154
+ """От этой ширины панель лога становится справа от беседы."""
155
+
156
+ MIN_WIDTH: Final = 80
157
+ """Ниже этой ширины панель лога скрывается: на 79 колонках две панели нечитаемы."""
158
+
159
+ _DEFAULT_XML_BUFFER: Final = 2000
160
+ """Размер кольцевого буфера строф по умолчанию, совпадает с умолчанием панели."""
161
+
162
+ _W = TypeVar("_W", bound=Widget)
163
+ """Тип виджета для поиска по селектору."""
164
+
165
+ _LOG_TITLE: Final = "RAW XML"
166
+ """Заголовок рамки панели сырого потока."""
167
+
168
+ _LOG_TITLE_UNSAFE: Final = "RAW XML UNSAFE"
169
+ """Заголовок панели, когда маскирование отключено."""
170
+
171
+ _PALETTE_PLACEHOLDER: Final = N_("› search commands")
172
+ """Подпись поля поиска в палитре: своя, на языке интерфейса, вместо подписи Textual."""
173
+
174
+ _ROSTER_PLACEHOLDER: Final = N_("› contact, room or occupant")
175
+ """Подпись поля поиска в палитре контактов."""
176
+
177
+ # Подпись записи участника комнаты в палитре: по ней видно, что это не контакт.
178
+ _OCCUPANT_HINT: Final = N_("room occupant")
179
+
180
+ _MAX_DOMAIN_WIDTH: Final = 24
181
+ """Предел колонки сервера в палитре контактов: длинный домен не должен съесть строку."""
182
+
183
+ _NAME_WIDTH: Final = 22
184
+ """Ширина колонки имени в палитре контактов."""
185
+
186
+ _LOCAL_WIDTH: Final = 14
187
+ """Ширина колонки локальной части JID: домен показан отдельной колонкой слева."""
188
+
189
+ _MUC_HINT: Final = N_("room")
190
+ """Подсказка вместо присутствия у комнаты: присутствие там означает вход, а не доступность."""
191
+
192
+ # Состояния подписки, при которых обмен присутствием полный. Остальные значения
193
+ # показываются в палитре: односторонняя подписка объясняет пустое присутствие.
194
+ _FULL_SUBSCRIPTIONS: Final[frozenset[str]] = frozenset({"both", ""})
195
+
196
+ _FILTER_USAGE: Final = N_(
197
+ "kind:<type> jid:<address> ns:<namespace> err:<yes|no> and free text, conditions joined by AND"
198
+ )
199
+ """Синтаксис фильтра панели лога. Печатается вместе с ошибкой разбора."""
200
+
201
+ # Ответы, которые считаются подтверждением небезопасного режима.
202
+ _CONFIRMATIONS: Final[frozenset[str]] = frozenset({"yes", "да"})
203
+
204
+ _SEND_QUESTION: Final = N_("type yes to send, any other answer cancels")
205
+ _UNSAFE_QUESTION: Final = N_("type yes to confirm, any other answer cancels")
206
+ """Вопросы, на которые ждет ответа строка ввода. Печатаются в ленте и в подсказке."""
207
+
208
+ # Клавиши, которые обрабатывает не приложение, а строка ввода или сам Textual.
209
+ # В таблице /keys они нужны, но объявлять их в BINDINGS нельзя: перехват сломает
210
+ # автодополнение и историю ввода.
211
+ _EXTERNAL_KEYS: Final[tuple[tuple[str, str], ...]] = (
212
+ ("ctrl+p", N_("command palette with fuzzy search")),
213
+ ("tab", N_("completion of command, JID, nick")),
214
+ ("up / down", N_("input history")),
215
+ ("ctrl+r", N_("reverse search in input history, ctrl+g or escape cancels")),
216
+ ("alt+enter / ctrl+j", N_("line break in multiline input")),
217
+ ("backspace", N_("restore the previous line of multiline input")),
218
+ ("enter", N_("send the line")),
219
+ ("ctrl+q", N_("quit without confirmation")),
220
+ )
221
+
222
+ # Клавиши панели RAW XML. Панель объявляет их у себя со show=False, в таблицу /keys
223
+ # они попадают отсюда вместе с единственным способом перевести на нее фокус.
224
+ # Клавиш, которые приложение уже объявило в BINDINGS (end, pageup, pagedown),
225
+ # здесь нет: они работают одинаково отовсюду, и вторая строка о них только мешает.
226
+ _PANEL_KEYS: Final[tuple[tuple[str, str], ...]] = (
227
+ ("shift+tab", N_("focus the RAW XML panel and back")),
228
+ ("up / down", N_("move the cursor through stanzas")),
229
+ ("enter", N_("expand the stanza under the cursor")),
230
+ )
231
+
232
+ # Шаблон, по которому в таблице клавиш схлопываются девять одинаковых строк
233
+ # alt+1..alt+9: они об одном действии.
234
+ _ALT_NUMBER_KEYS: Final = tuple(f"alt+{number}" for number in range(1, 10))
235
+
236
+ # Названия языков для /lang. Переводятся на текущий язык интерфейса.
237
+ _LANGUAGE_NAMES: Final[Mapping[str, str]] = MappingProxyType(
238
+ {"en": N_("English"), "ru": N_("Russian")}
239
+ )
240
+
241
+
242
+ def _language_name(code: str) -> str:
243
+ """Название языка на текущем языке. Язык без названия показывается кодом."""
244
+ name = _LANGUAGE_NAMES.get(code)
245
+ return _(name) if name is not None else code
246
+
247
+
248
+ class QuitConfirmScreen(ModalScreen[bool]):
249
+ """Подтверждение выхода по Ctrl+C.
250
+
251
+ Textual перехватывает Ctrl+C сам и только показывает подсказку, поэтому клавиша
252
+ переопределена в приложении, а подтверждение сделано отдельным модальным экраном.
253
+ Экран не требует терминала и работает под ``App.run_test``.
254
+ """
255
+
256
+ # Буквы продублированы кириллицей: на русской раскладке латинские y и n не
257
+ # приходят вовсе, и диалог оставался без ответа. Кириллические выбраны по
258
+ # смыслу, а не по позиции клавиши: д - да, н - нет. Физическая клавиша y на
259
+ # русской раскладке дает как раз "н", и трактовать ее как согласие значило
260
+ # бы выходить из клиента по нажатию, которое пользователь считает отказом.
261
+ # Ctrl+C продублирован по позиции: там буква не читается пользователем, а
262
+ # служит именем клавиши. Escape от раскладки не зависит вовсе.
263
+ BINDINGS: ClassVar[list[BindingType]] = with_cyrillic(
264
+ [
265
+ Binding("y", "confirm", N_("leave"), priority=True),
266
+ Binding("д", "confirm", N_("leave"), priority=True, show=False),
267
+ Binding("enter", "confirm", N_("leave"), priority=True),
268
+ Binding("n", "cancel", N_("stay"), priority=True),
269
+ Binding("н", "cancel", N_("stay"), priority=True, show=False),
270
+ Binding("escape", "cancel", N_("stay"), priority=True),
271
+ Binding("ctrl+c", "confirm", N_("leave"), priority=True, show=False),
272
+ ]
273
+ )
274
+
275
+ def compose(self) -> ComposeResult:
276
+ """Собрать диалог подтверждения."""
277
+ # Кириллические клавиши подставляются, а не стоят в ключе перевода: это
278
+ # имена клавиш, и на любом языке интерфейса они одни и те же.
279
+ text = _(
280
+ "Quit termisations?\n\n"
281
+ "Ctrl+C again - quit immediately\n"
282
+ "y, {yes_key}, Enter - quit. n, {no_key}, Esc - stay"
283
+ ).format(yes_key="д", no_key="н")
284
+ yield Static(text, id="quit-dialog")
285
+
286
+ def action_confirm(self) -> None:
287
+ """Подтвердить выход."""
288
+ self.dismiss(True)
289
+
290
+ def action_cancel(self) -> None:
291
+ """Отменить выход."""
292
+ self.dismiss(False)
293
+
294
+
295
+ class SlashCommandProvider(Provider):
296
+ """Поставщик слэш-команд для штатной палитры Textual (Ctrl+P).
297
+
298
+ Свой нечеткий поиск не пишется: палитра Textual уже умеет ранжировать и
299
+ подсвечивать совпадения, нужен только источник строк. Источник - реестр
300
+ ``core.commands.REGISTRY``, поэтому новая команда появляется в палитре
301
+ без правок этого файла.
302
+ """
303
+
304
+ def _target(self) -> "TermisationsApp | None":
305
+ """Приложение, которому адресована команда. None, если провайдер вне termisations."""
306
+ app = self.app
307
+ return app if isinstance(app, TermisationsApp) else None
308
+
309
+ async def discover(self) -> Hits:
310
+ """Показать весь реестр, пока запрос пустой."""
311
+ target = self._target()
312
+ if target is None:
313
+ return
314
+ for spec in commands.REGISTRY:
315
+ yield DiscoveryHit(
316
+ f"/{spec.name}",
317
+ partial(target.run_palette_command, spec),
318
+ text=f"/{spec.name}",
319
+ help=_(spec.summary),
320
+ )
321
+
322
+ async def search(self, query: str) -> Hits:
323
+ """Отобрать команды по запросу палитры."""
324
+ target = self._target()
325
+ if target is None:
326
+ return
327
+ matcher = self.matcher(query)
328
+ for spec in commands.REGISTRY:
329
+ candidate = f"/{spec.name}"
330
+ # Алиасы участвуют в поиске, но показывается всегда основное имя:
331
+ # иначе одна команда попадает в список несколько раз.
332
+ score = max(
333
+ (matcher.match(f"/{alias}") for alias in spec.aliases),
334
+ default=0.0,
335
+ )
336
+ score = max(score, matcher.match(candidate))
337
+ if score > 0:
338
+ yield Hit(
339
+ score,
340
+ matcher.highlight(candidate),
341
+ partial(target.run_palette_command, spec),
342
+ text=candidate,
343
+ help=_(spec.summary),
344
+ )
345
+
346
+
347
+ @dataclass(frozen=True, slots=True)
348
+ class RosterEntry:
349
+ """Строка палитры контактов: контакт из roster или открытая беседа."""
350
+
351
+ jid: str
352
+ title: str
353
+ show: PresenceShow
354
+ is_muc: bool = False
355
+ unread: int = 0
356
+ subscription: str = ""
357
+ status: str = ""
358
+
359
+ @property
360
+ def domain(self) -> str:
361
+ """Сервер контакта. По нему записи группируются в палитре."""
362
+ _local, _sep, domain = self.jid.partition("@")
363
+ return domain or self.jid
364
+
365
+ @property
366
+ def local(self) -> str:
367
+ """Локальная часть JID. Домен показан отдельной колонкой."""
368
+ local, _sep, _domain = self.jid.partition("@")
369
+ return local or self.jid
370
+
371
+ @property
372
+ def online(self) -> bool:
373
+ """Контакт доступен. У комнаты означает, что вход выполнен."""
374
+ return self.show is not PresenceShow.OFFLINE
375
+
376
+ @property
377
+ def hint(self) -> str:
378
+ """Правая колонка строки: то, чего не видно по знаку присутствия.
379
+
380
+ Подсказка вписана в саму строку, а не отдана палитре полем help: help
381
+ Textual печатает отдельной строкой во всю ширину, и список контактов
382
+ становится вдвое выше без единого нового слова.
383
+ """
384
+ parts: list[str] = []
385
+ if self.is_muc:
386
+ parts.append(_(_MUC_HINT))
387
+ if self.unread:
388
+ parts.append(f"+{self.unread}")
389
+ if self.status:
390
+ parts.append(self.status)
391
+ elif not self.is_muc and self.show is not PresenceShow.AVAILABLE:
392
+ # Текст статуса важнее подписи присутствия: знак слева ее уже передал.
393
+ parts.append(_(PRESENCE_LABELS[self.show]))
394
+ if self.subscription and self.subscription not in _FULL_SUBSCRIPTIONS:
395
+ parts.append(_("subscription {state}").format(state=self.subscription))
396
+ return ", ".join(parts)
397
+
398
+
399
+ def _entry_order(entry: RosterEntry) -> tuple[str, int, str]:
400
+ """Порядок в палитре: сервер, затем доступные контакты, затем имя."""
401
+ return (entry.domain, 0 if entry.show is not PresenceShow.OFFLINE else 1, entry.title.lower())
402
+
403
+
404
+ def _domain_width(entries: Sequence[RosterEntry]) -> int:
405
+ """Ширина колонки сервера. Ограничена, чтобы длинный домен не съел строку."""
406
+ if not entries:
407
+ return 0
408
+ return min(max(len(entry.domain) for entry in entries), _MAX_DOMAIN_WIDTH)
409
+
410
+
411
+ def _entry_display(entry: RosterEntry, domain_width: int, show_domain: bool) -> Text:
412
+ """Строка контакта с колонкой сервера.
413
+
414
+ Домен печатается только у первой записи группы: повтор у каждой строки
415
+ превращает список в столбец одинакового текста. Палитра Textual не умеет
416
+ невыбираемых строк, поэтому группа обозначена колонкой, а не разделителем.
417
+ """
418
+ mark, mark_style = PRESENCE_MARKS[entry.show]
419
+ domain = _ellipsis(entry.domain, domain_width) if show_domain else ""
420
+ line = Text(f"{domain:<{domain_width}} ", style="bold" if show_domain else "")
421
+ line.append(f"{MUC_MARK if entry.is_muc else mark} ", style=mark_style)
422
+ line.append(_ellipsis(entry.title, _NAME_WIDTH).ljust(_NAME_WIDTH))
423
+ line.append(f" {entry.local:<{_LOCAL_WIDTH}}", style="dim")
424
+ if entry.hint:
425
+ line.append(f" {entry.hint}", style="dim italic")
426
+ return line
427
+
428
+
429
+ def _ellipsis(text: str, width: int) -> str:
430
+ """Усечь строку до ширины колонки, пометив усечение многоточием."""
431
+ if width <= 0 or len(text) <= width:
432
+ return text
433
+ return text[: max(width - 1, 0)] + "…"
434
+
435
+
436
+ class RosterProvider(Provider):
437
+ """Поставщик контактов для палитры перехода по Ctrl+O.
438
+
439
+ Устроен так же, как палитра команд: нечеткий поиск и ранжирование берутся у
440
+ Textual, отсюда приходит только источник строк. Источник - контакт-лист плюс
441
+ открытые беседы, которых в контакт-листе нет: комнаты в roster не попадают,
442
+ но перейти в них нужно так же.
443
+ """
444
+
445
+ def _target(self) -> "TermisationsApp | None":
446
+ """Приложение, которому адресован переход. None, если провайдер вне termisations."""
447
+ app = self.app
448
+ return app if isinstance(app, TermisationsApp) else None
449
+
450
+ async def discover(self) -> Hits:
451
+ """Показать весь список, пока запрос пустой: записи сгруппированы по серверу."""
452
+ target = self._target()
453
+ if target is None:
454
+ return
455
+ entries = target.roster_entries()
456
+ width = _domain_width(entries)
457
+ previous = ""
458
+ for entry in entries:
459
+ display = _entry_display(entry, width, show_domain=entry.domain != previous)
460
+ previous = entry.domain
461
+ yield DiscoveryHit(
462
+ display,
463
+ partial(target.open_from_palette, entry.jid),
464
+ text=entry.jid,
465
+ )
466
+
467
+ async def search(self, query: str) -> Hits:
468
+ """Отобрать контакты по запросу. Ищется и имя, и адрес целиком.
469
+
470
+ Группировка по серверу здесь не показывается: строки отбираются по
471
+ совпадению, соседство по домену случайно, а колонка домена только
472
+ мешала бы подсветке. Вместо нее печатается полный JID.
473
+ """
474
+ target = self._target()
475
+ if target is None:
476
+ return
477
+ matcher = self.matcher(query)
478
+ for entry in target.roster_entries():
479
+ candidate = entry.jid if entry.title == entry.jid else f"{entry.title} {entry.jid}"
480
+ score = matcher.match(candidate)
481
+ if score > 0:
482
+ yield Hit(
483
+ score,
484
+ matcher.highlight(candidate),
485
+ partial(target.open_from_palette, entry.jid),
486
+ text=entry.jid,
487
+ help=entry.hint or None,
488
+ )
489
+
490
+
491
+ class TermisationsApp(App[None]):
492
+ """Textual-приложение клиента: раскладка, клавиши, маршрутизация ввода."""
493
+
494
+ TITLE = "termisations"
495
+ SUB_TITLE = N_("XMPP with a transparent protocol")
496
+ CSS_PATH = "app.tcss"
497
+
498
+ # Фокус ставится вручную на строку ввода, автоподбор первого виджета не нужен.
499
+ AUTO_FOCUS = None
500
+
501
+ ENABLE_COMMAND_PALETTE = True
502
+ COMMANDS: ClassVar[set[type[Provider] | Callable[[], type[Provider]]]] = {
503
+ *App.COMMANDS,
504
+ SlashCommandProvider,
505
+ }
506
+
507
+ # priority=True обязателен там, где клавиша занята полем ввода: Input держит
508
+ # ctrl+d на удаление символа, а ctrl+c на копирование. Без приоритета
509
+ # приложение эти клавиши не увидит, пока фокус в строке ввода.
510
+ BINDINGS: ClassVar[list[BindingType]] = with_cyrillic(
511
+ [
512
+ Binding("ctrl+d", "cycle_layout", N_("layout"), priority=True),
513
+ Binding("ctrl+l", "clear_log", N_("clear log"), priority=True),
514
+ Binding("ctrl+f", "filter_log", N_("log filter"), priority=True),
515
+ # Ctrl+O, а не Ctrl+R: обратный поиск по истории ввода занял Ctrl+R,
516
+ # как в readline и bash. Буква тут по действию - палитра открывает
517
+ # беседу с выбранным контактом.
518
+ Binding("ctrl+o", "roster_palette", N_("contact list"), priority=True),
519
+ Binding("ctrl+n", "next_conversation", N_("next conversation"), priority=True),
520
+ Binding("ctrl+b", "prev_conversation", N_("previous conversation"), priority=True),
521
+ Binding("ctrl+c", "request_quit", N_("quit"), priority=True),
522
+ Binding("escape", "reset", N_("reset filter and mode"), priority=True, show=False),
523
+ Binding("pageup", "page_up", N_("scroll up"), show=False),
524
+ Binding("pagedown", "page_down", N_("scroll down"), show=False),
525
+ # end работает из строки ввода: иначе из pause, вызванного курсором панели,
526
+ # выйти нечем, кроме ctrl+l, а он очищает буфер.
527
+ Binding(
528
+ "end",
529
+ "follow_end",
530
+ N_("go to the end of the feed and the stream, leave pause"),
531
+ priority=True,
532
+ show=False,
533
+ ),
534
+ *[
535
+ Binding(
536
+ f"alt+{number}",
537
+ f"goto_conversation({number})",
538
+ N_("conversation by number"),
539
+ show=False,
540
+ )
541
+ for number in range(1, 10)
542
+ ],
543
+ ]
544
+ )
545
+
546
+ layout_mode: reactive[str] = reactive("split", init=False)
547
+ """Текущая раскладка: focus, split или debug."""
548
+
549
+ def __init__(
550
+ self,
551
+ bus: EventBus,
552
+ session: Session,
553
+ *,
554
+ layout: str = "split",
555
+ xml_buffer: int | None = None,
556
+ input_log: InputLog | None = None,
557
+ ) -> None:
558
+ """Собрать приложение поверх готовой шины и сессии.
559
+
560
+ Шина и сессия создаются снаружи (в ``cli.main``), приложение только связывает
561
+ их с виджетами. Это позволяет поднять те же объекты в тесте без терминала.
562
+
563
+ ``input_log`` - общая для всех профилей история ввода, уже открытая и
564
+ прочитанная. ``None`` означает работу без нее: так запускается клиент с
565
+ ``--no-history`` и клиент, которому файл истории оказался недоступен.
566
+ """
567
+ super().__init__()
568
+ self._bus = bus
569
+ self._session = session
570
+ self._initial_layout = layout if layout in LAYOUT_MODES else "split"
571
+ self._xml_buffer = xml_buffer
572
+ self._input_log = input_log
573
+ self._subs = Subscription()
574
+
575
+ # Ссылки на панели заполняются в on_mount: до монтирования дерева их нет.
576
+ self._chat: ChatPanel | None = None
577
+ self._xmllog: XmlLogPanel | None = None
578
+ self._prompt: PromptInput | None = None
579
+ self._status: StatusBar | None = None
580
+
581
+ # Локальная копия состояния только для вывода команд и навигации.
582
+ # Источник правды - сессия, здесь хранится последний присланный снимок.
583
+ self._state = ClientState()
584
+ self._conversations: tuple[Conversation, ...] = ()
585
+ self._active: str | None = None
586
+ self._roster: tuple[RosterItem, ...] = ()
587
+ # Участники комнат по JID комнаты: источник вариантов дополнения ника.
588
+ self._occupants: dict[str, tuple[str, ...]] = {}
589
+ self._xml_mode = XmlMode.BOTH
590
+ self._xml_filter = ""
591
+ self._unsafe = False
592
+
593
+ # Собственный буфер строф нужен команде /log save: панель лога хранит
594
+ # буфер у себя, но контракт не дает метода для его чтения. Хранятся ссылки
595
+ # на те же неизменяемые объекты, копирования текста не происходит.
596
+ self._stanzas: deque[RawStanza] = deque(maxlen=xml_buffer or _DEFAULT_XML_BUFFER)
597
+
598
+ self._pending_unsafe: XmlMode | None = None
599
+ self._awaiting_unsafe = False
600
+ # Строфа, ожидающая подтверждения командой /send. Механизм подтверждения
601
+ # один и тот же: ввод yes, любой другой ответ отменяет.
602
+ self._pending_send: str | None = None
603
+ self._startup_notices: list[str] = []
604
+ self._log_hidden = False
605
+ self._started = time.monotonic()
606
+
607
+ # Команда исполняется сессией, приложение только кладет ее в очередь.
608
+ self._bus.set_command_handler(self._session.handle_command)
609
+
610
+ # Разбор по handler_key вместо цепочки if: реестр команд - это данные.
611
+ # Набор ключей обязан совпадать с router.UI_KEYS: там записано, какие
612
+ # команды исполняет интерфейс, и сессия на них отвечает отказом. Равенство
613
+ # проверяет tests/test_router.py, расхождение означает команду без хозяина.
614
+ self._local: dict[str, Callable[[commands.ParsedCommand], None]] = {
615
+ "sys.help": self._cmd_help,
616
+ "sys.keys": self._cmd_keys,
617
+ "sys.theme": self._cmd_theme,
618
+ "sys.lang": self._cmd_lang,
619
+ "sys.quit": self._cmd_quit,
620
+ "sys.log.save": self._cmd_log_save,
621
+ "chat.clear": self._cmd_clear,
622
+ "debug.send": self._cmd_send,
623
+ "debug.panel": self._cmd_debug_panel,
624
+ "debug.xml": self._cmd_xml,
625
+ "debug.xml.filter": self._cmd_xml_filter,
626
+ "debug.stats": self._cmd_stats,
627
+ }
628
+
629
+ # -- сборка дерева -------------------------------------------------------
630
+
631
+ def _create_xml_log(self) -> XmlLogPanel:
632
+ """Создать панель лога с размером кольцевого буфера из аргументов запуска."""
633
+ if self._xml_buffer is None:
634
+ return XmlLogPanel(self._bus, id="xmllog")
635
+ return XmlLogPanel(self._bus, capacity=self._xml_buffer, id="xmllog")
636
+
637
+ def _create_prompt(self) -> PromptInput:
638
+ """Создать строку ввода с историей прошлых запусков."""
639
+ if self._input_log is None:
640
+ return PromptInput(self._bus, id="prompt")
641
+ return PromptInput(
642
+ self._bus,
643
+ id="prompt",
644
+ history=self._input_log.lines,
645
+ record=self._record_input,
646
+ )
647
+
648
+ def _record_input(self, line: str) -> None:
649
+ """Записать введенную строку в общую историю.
650
+
651
+ Запись идет воркером: файл истории лежит на том же диске, что база
652
+ переписки, и синхронная дозапись превратилась бы в паузу на каждый Enter.
653
+ Отказ записи переписке не мешает и наружу не идет: ``InputLog`` отмечает
654
+ его в журнале сам.
655
+ """
656
+ log = self._input_log
657
+ if log is None or not log.writable:
658
+ return
659
+ self.run_worker(log.append(line), name="input-history", group="io", exit_on_error=False)
660
+
661
+ def compose(self) -> ComposeResult:
662
+ """Дерево виджетов. Состав постоянный, меняются только классы ``#body``."""
663
+ with Vertical(id="root"):
664
+ with Container(id="body", classes="-mode-split -log-right"):
665
+ yield ChatPanel(self._bus, id="chat")
666
+ yield self._create_xml_log()
667
+ yield self._create_prompt()
668
+ yield StatusBar(self._bus, id="statusbar")
669
+
670
+ def on_mount(self) -> None:
671
+ """Запомнить панели, подписаться на шину и запустить фоновые задачи."""
672
+ self._chat = self._find("#chat", ChatPanel)
673
+ self._xmllog = self._find("#xmllog", XmlLogPanel)
674
+ self._prompt = self._find("#prompt", PromptInput)
675
+ self._status = self._find("#statusbar", StatusBar)
676
+
677
+ self.apply_language()
678
+
679
+ self._subs.add(self._bus.subscribe(StateUpdated, self._on_state))
680
+ self._subs.add(self._bus.subscribe(ConversationsUpdated, self._on_conversations))
681
+ self._subs.add(self._bus.subscribe(ActiveConversationChanged, self._on_active))
682
+ self._subs.add(self._bus.subscribe(RosterUpdated, self._on_roster))
683
+ self._subs.add(self._bus.subscribe(OccupantsUpdated, self._on_occupants))
684
+ self._subs.add(self._bus.subscribe(UnsafeModeChanged, self._on_unsafe))
685
+ self._subs.add(self._bus.subscribe(XmlLogModeChanged, self._on_xml_mode))
686
+ self._subs.add(self._bus.subscribe(StanzaLogged, self._on_stanza))
687
+
688
+ self.layout_mode = self._initial_layout
689
+ self._apply_layout_classes()
690
+ self._apply_width(self.size.width)
691
+
692
+ # Шина и сессия живут воркерами Textual: их отменяет сам фреймворк при
693
+ # завершении приложения, отдельная сборка мусора не нужна.
694
+ self.run_worker(self._bus.run(), name="bus", group="core", exit_on_error=False)
695
+ self.run_worker(self._session.run(), name="session", group="core", exit_on_error=False)
696
+
697
+ if self._prompt is not None:
698
+ self._prompt.focus_input()
699
+ for text in self._startup_notices:
700
+ self._notice(text, NoticeLevel.WARNING)
701
+ self._startup_notices.clear()
702
+
703
+ def apply_language(self) -> None:
704
+ """Выставить статический текст приложения на текущем языке.
705
+
706
+ Заголовки рамок рисует приложение: рамки заданы в app.tcss, а текста
707
+ заголовка в CSS нет, он задается свойством виджета. Панели выставляют
708
+ свой текст сами, их ``apply_language`` вызывает команда /lang.
709
+ """
710
+ self.sub_title = _(self.SUB_TITLE)
711
+ if self._chat is not None:
712
+ self._chat.border_title = self.TITLE
713
+ if self._xmllog is not None:
714
+ self._xmllog.border_title = _LOG_TITLE_UNSAFE if self._unsafe else _LOG_TITLE
715
+
716
+ def on_unmount(self) -> None:
717
+ """Снять подписки и остановить фоновые циклы."""
718
+ self._subs.close()
719
+ self._session.stop()
720
+ self._bus.stop()
721
+
722
+ def _find(self, selector: str, kind: type[_W]) -> _W | None:
723
+ """Найти виджет по селектору. None вместо исключения, если его еще нет."""
724
+ try:
725
+ return self.query_one(selector, kind)
726
+ except NoMatches:
727
+ return None
728
+
729
+ # -- раскладка -----------------------------------------------------------
730
+
731
+ def watch_layout_mode(self, mode: str) -> None:
732
+ """Применить классы раскладки при смене режима."""
733
+ del mode
734
+ self._apply_layout_classes()
735
+
736
+ def _apply_layout_classes(self) -> None:
737
+ """Проставить классы режима на контейнере ``#body``."""
738
+ body = self._find("#body", Container)
739
+ if body is None:
740
+ return
741
+ for name in LAYOUT_MODES:
742
+ body.set_class(name == self.layout_mode, f"-mode-{name}")
743
+
744
+ def _apply_width(self, width: int) -> None:
745
+ """Выбрать положение панели лога по ширине терминала."""
746
+ body = self._find("#body", Container)
747
+ if body is None:
748
+ return
749
+ if width < MIN_WIDTH:
750
+ target = "-log-hidden"
751
+ elif width >= WIDE_WIDTH:
752
+ target = "-log-right"
753
+ else:
754
+ target = "-log-bottom"
755
+ for name in ("-log-right", "-log-bottom", "-log-hidden"):
756
+ body.set_class(name == target, name)
757
+
758
+ hidden = target == "-log-hidden"
759
+ if hidden and not self._log_hidden:
760
+ self.notify(
761
+ _(
762
+ "width of {width} columns is below {minimum}: the RAW XML panel is hidden"
763
+ ).format(width=width, minimum=MIN_WIDTH),
764
+ title=_("layout"),
765
+ severity="warning",
766
+ )
767
+ self._log_hidden = hidden
768
+
769
+ def on_resize(self, event: Resize) -> None:
770
+ """Пересчитать раскладку под новую ширину терминала."""
771
+ self._apply_width(event.size.width)
772
+
773
+ # -- действия клавиш -----------------------------------------------------
774
+
775
+ def action_cycle_layout(self) -> None:
776
+ """Циклически переключить раскладку: focus, split, debug."""
777
+ index = LAYOUT_MODES.index(self.layout_mode) if self.layout_mode in LAYOUT_MODES else 1
778
+ self.layout_mode = LAYOUT_MODES[(index + 1) % len(LAYOUT_MODES)]
779
+ self._notice(_("layout: {mode}").format(mode=self.layout_mode) + self._layout_reason())
780
+
781
+ def _layout_reason(self) -> str:
782
+ """Причина, по которой выбранный режим не меняет картинку."""
783
+ if not self._log_hidden:
784
+ return ""
785
+ return _(" (log hidden, width {width} is below {minimum})").format(
786
+ width=self.size.width, minimum=MIN_WIDTH
787
+ )
788
+
789
+ def action_follow_end(self) -> None:
790
+ """Вернуть обе панели к концу потока.
791
+
792
+ Курсор панели лога переводит ее в pause, и без этой клавиши выйти из
793
+ pause из строки ввода нечем.
794
+ """
795
+ if self._xmllog is not None:
796
+ self._xmllog.action_follow()
797
+ if self._chat is not None:
798
+ self._chat.scroll_to_end()
799
+
800
+ def action_clear_log(self) -> None:
801
+ """Очистить панель сырого XML."""
802
+ if self._xmllog is None:
803
+ return
804
+ self._xmllog.clear()
805
+ self._stanzas.clear()
806
+ self._notice(_("RAW XML panel cleared"))
807
+
808
+ def action_filter_log(self) -> None:
809
+ """Подставить в строку ввода заготовку фильтра лога."""
810
+ self.prefill_prompt("/xml filter ")
811
+
812
+ def action_reset(self) -> None:
813
+ """Сбросить фильтр лога, отменить ожидание подтверждения, вернуть фокус."""
814
+ if CommandPalette.is_open(self.app):
815
+ # Палитра закрывается сама, но привязка Escape у приложения стоит с
816
+ # priority и перехватывает клавишу раньше нее. Без этой ветки из
817
+ # списка контактов по Ctrl+O выйти было нечем.
818
+ self.screen.dismiss(None)
819
+ return
820
+ if isinstance(self.screen, QuitConfirmScreen):
821
+ self.screen.dismiss(False)
822
+ return
823
+ # Обратный поиск по истории отменяется отсюда: привязка Escape у
824
+ # приложения стоит с priority, и до строки ввода клавиша не доходит.
825
+ if self._prompt is not None and self._prompt.cancel_search():
826
+ return
827
+ if self._awaiting_unsafe:
828
+ self._cancel_unsafe()
829
+ return
830
+ if self._xml_filter:
831
+ self._set_filter("")
832
+ if self._prompt is not None:
833
+ self._prompt.focus_input()
834
+
835
+ def action_next_conversation(self) -> None:
836
+ """Перейти к следующей беседе."""
837
+ self._step_conversation(1)
838
+
839
+ def action_prev_conversation(self) -> None:
840
+ """Перейти к предыдущей беседе."""
841
+ self._step_conversation(-1)
842
+
843
+ def action_goto_conversation(self, index: int) -> None:
844
+ """Перейти к беседе по ее номеру в списке, нумерация с единицы."""
845
+ if not self._conversations:
846
+ self._notice(_("conversation list is empty"), NoticeLevel.WARNING)
847
+ return
848
+ if not 1 <= index <= len(self._conversations):
849
+ self._notice(
850
+ _("there is no conversation number {index}").format(index=index),
851
+ NoticeLevel.WARNING,
852
+ )
853
+ return
854
+ self._bus.dispatch(OpenConversation(self._conversations[index - 1].jid))
855
+
856
+ def action_page_up(self) -> None:
857
+ """Прокрутить активную панель на экран вверх."""
858
+ target = self._scroll_target()
859
+ if target is not None:
860
+ target.scroll_page_up(animate=False)
861
+
862
+ def action_page_down(self) -> None:
863
+ """Прокрутить активную панель на экран вниз.
864
+
865
+ Панель лога сама вернется в follow, когда прокрутка дойдет до конца:
866
+ признак ``_paused_by_scroll`` ставится и при уходе курсором.
867
+ """
868
+ target = self._scroll_target()
869
+ if target is not None:
870
+ target.scroll_page_down(animate=False)
871
+
872
+ def action_request_quit(self) -> None:
873
+ """Копировать выделенное, а если выделения нет - запросить выход.
874
+
875
+ Ctrl+C в терминале означает две разные вещи, и обе нужны. Textual вешает
876
+ на него копирование выделенного текста, а ТЗ требует выход по этой же
877
+ клавише. Приоритетная привязка приложения забирала клавишу целиком, и
878
+ скопировать выделенное мышью было нечем.
879
+ """
880
+ if isinstance(self.screen, QuitConfirmScreen):
881
+ # Второе нажатие подряд: подтверждение уже на экране, спрашивать
882
+ # снова незачем. Ctrl+C не зависит от раскладки, поэтому такой выход
883
+ # работает там, где буквы диалога недоступны.
884
+ self.exit()
885
+ return
886
+ selected = self.screen.get_selected_text()
887
+ if selected:
888
+ self.copy_to_clipboard(selected)
889
+ # Выделение снимается сразу: иначе второе нажатие снова копировало
890
+ # бы тот же текст, и до выхода дело не дошло бы никогда.
891
+ self.screen.clear_selection()
892
+ self._notice(
893
+ _("characters copied: {count}").format(count=len(selected)), NoticeLevel.SUCCESS
894
+ )
895
+ return
896
+ self.push_screen(QuitConfirmScreen(), self._on_quit_answer)
897
+
898
+ def _on_quit_answer(self, confirmed: bool | None) -> None:
899
+ """Обработать ответ модального экрана подтверждения."""
900
+ if confirmed:
901
+ self.exit()
902
+
903
+ def _step_conversation(self, delta: int) -> None:
904
+ """Сдвинуться по списку бесед на delta позиций по кругу."""
905
+ if not self._conversations:
906
+ self._notice(_("conversation list is empty"), NoticeLevel.WARNING)
907
+ return
908
+ jids = [item.jid for item in self._conversations]
909
+ current = jids.index(self._active) if self._active in jids else 0
910
+ self._bus.dispatch(OpenConversation(jids[(current + delta) % len(jids)]))
911
+
912
+ def _scroll_target(self) -> Widget | None:
913
+ """Панель, которую прокручивают PgUp и PgDn.
914
+
915
+ В режиме debug это лог, в остальных - беседа: прокручивается то, что видно.
916
+ """
917
+ selector = "#xmllog-body" if self.layout_mode == "debug" else "#chat-log"
918
+ target = self._find(selector, Widget)
919
+ if target is None:
920
+ target = self._find("#chat-log", Widget)
921
+ return target
922
+
923
+ # -- подписки ------------------------------------------------------------
924
+
925
+ def _on_state(self, event: StateUpdated) -> None:
926
+ """Запомнить снимок состояния для команд /stats и /xml."""
927
+ self._state = event.state
928
+
929
+ def _on_conversations(self, event: ConversationsUpdated) -> None:
930
+ """Обновить список бесед для навигации и автодополнения."""
931
+ self._conversations = event.items
932
+ self._push_context()
933
+
934
+ def _on_active(self, event: ActiveConversationChanged) -> None:
935
+ """Запомнить активную беседу и передать ее в строку ввода."""
936
+ self._active = event.jid
937
+ self._push_context()
938
+
939
+ def _on_roster(self, event: RosterUpdated) -> None:
940
+ """Обновить контакт-лист для автодополнения."""
941
+ self._roster = event.items
942
+ self._push_context()
943
+
944
+ def _on_occupants(self, event: OccupantsUpdated) -> None:
945
+ """Запомнить состав комнаты для автодополнения ника."""
946
+ self._occupants[event.jid] = event.nicks
947
+ self._push_context()
948
+
949
+ @property
950
+ def _nicks(self) -> tuple[str, ...]:
951
+ """Ники участников активной комнаты. Для личной беседы пусто."""
952
+ if self._active is None:
953
+ return ()
954
+ return self._occupants.get(self._active, ())
955
+
956
+ def _on_unsafe(self, event: UnsafeModeChanged) -> None:
957
+ """Принять смену режима маскирования, пришедшую от сессии."""
958
+ self._unsafe = event.enabled
959
+ self._mark_unsafe(event.enabled)
960
+ if self._xmllog is not None:
961
+ self._xmllog.set_unsafe(event.enabled)
962
+
963
+ def _mark_unsafe(self, enabled: bool) -> None:
964
+ """Показать или убрать признак отключенного маскирования.
965
+
966
+ Признак дублируется намеренно. Класс на статус-баре виджет ставит и сам,
967
+ по полю ``ClientState.unsafe_xml``, и очередной снимок состояния его
968
+ перебьет. Класс на ``#root`` и заголовок панели принадлежат приложению,
969
+ их никто не перезапишет, поэтому предупреждение не исчезнет незаметно.
970
+ """
971
+ self.set_class(enabled, "-unsafe")
972
+ root = self._find("#root", Vertical)
973
+ if root is not None:
974
+ root.set_class(enabled, "-unsafe")
975
+ if self._status is not None:
976
+ self._status.set_class(enabled, "-unsafe")
977
+ if self._xmllog is not None:
978
+ self._xmllog.border_title = _LOG_TITLE_UNSAFE if enabled else _LOG_TITLE
979
+
980
+ def _on_xml_mode(self, event: XmlLogModeChanged) -> None:
981
+ """Запомнить режим лога и применить его к панели."""
982
+ self._xml_mode = event.mode
983
+ if self._xmllog is not None:
984
+ self._xmllog.set_mode(event.mode)
985
+
986
+ def _on_stanza(self, event: StanzaLogged) -> None:
987
+ """Горячий путь: только положить строфу в буфер для /log save."""
988
+ self._stanzas.append(event.stanza)
989
+
990
+ def _push_context(self) -> None:
991
+ """Передать строке ввода контекст автодополнения целиком.
992
+
993
+ Кроме roster передаются открытые беседы, ники участников активной комнаты,
994
+ множество контактов в сети и фактический список тем оформления. Без них
995
+ дополнение ника и порядок "контакты в сети первыми" не работают вовсе.
996
+ """
997
+ if self._prompt is None:
998
+ return
999
+ known = [item.jid for item in self._roster]
1000
+ known.extend(item.jid for item in self._conversations if item.jid not in known)
1001
+ self._prompt.set_context(
1002
+ self._active,
1003
+ known,
1004
+ conversations=[item.jid for item in self._conversations],
1005
+ nicks=self._nicks,
1006
+ online=[item.jid for item in self._roster if item.online],
1007
+ themes=sorted(self.available_themes),
1008
+ )
1009
+
1010
+ # -- ввод ----------------------------------------------------------------
1011
+
1012
+ @on(PromptInput.Typing)
1013
+ def _on_typing(self, event: PromptInput.Typing) -> None:
1014
+ """Состояние набора уходит собеседнику активной беседы.
1015
+
1016
+ Решение принимает приложение: строка ввода не знает ни об активной
1017
+ беседе, ни о сети. Без активной беседы отправлять состояние некому.
1018
+ """
1019
+ event.stop()
1020
+ if self._active is None:
1021
+ return
1022
+ self._bus.dispatch(SetChatState(self._active, "composing" if event.active else "paused"))
1023
+
1024
+ @on(PromptInput.Submitted)
1025
+ def _on_submitted(self, event: PromptInput.Submitted) -> None:
1026
+ """Принять строку из поля ввода."""
1027
+ event.stop()
1028
+ self.submit_line(event.line)
1029
+
1030
+ def submit_line(self, line: str) -> None:
1031
+ """Обработать введенную строку.
1032
+
1033
+ Порядок разбора: подтверждение небезопасного режима, затем разбор команды,
1034
+ затем локальные обработчики, и только потом отправка команды в шину.
1035
+ Обычный текст и экранированный ``//`` уходят в шину целиком: снимает слэш
1036
+ и решает, кому адресован текст, роутер сессии.
1037
+ """
1038
+ text = line.strip()
1039
+ if not text:
1040
+ return
1041
+ # Отправка строки означает возврат к концу ленты: пользователь ждет ответ
1042
+ # именно на нее, и он не должен уйти за нижнюю границу экрана.
1043
+ if self._chat is not None:
1044
+ self._chat.scroll_to_end()
1045
+ if self._awaiting_unsafe:
1046
+ self._resolve_unsafe(text)
1047
+ return
1048
+ if self._pending_send is not None:
1049
+ self._resolve_send(text)
1050
+ return
1051
+
1052
+ parsed = commands.parse(text)
1053
+ if parsed is None:
1054
+ self._bus.dispatch(RunCommandLine(text))
1055
+ return
1056
+ if not parsed.ok:
1057
+ # Текст ошибки уже содержит ожидаемую сигнатуру, общий текст
1058
+ # "неверная команда" запрещен ТЗ.
1059
+ error = parsed.error or _("unknown command: {text}").format(text=text)
1060
+ self._notice(error, NoticeLevel.ERROR)
1061
+ return
1062
+
1063
+ handler = self._local.get(parsed.handler_key)
1064
+ if handler is not None:
1065
+ handler(parsed)
1066
+ return
1067
+ self._bus.dispatch(RunCommandLine(text))
1068
+
1069
+ def action_command_palette(self) -> None:
1070
+ """Открыть палитру команд с подписью поля поиска на языке интерфейса."""
1071
+ # is_open объявлен на App[object]; дженерик App[None] к нему не приводится
1072
+ # автоматически, хотя тип параметра здесь роли не играет.
1073
+ opened = CommandPalette.is_open(self.app)
1074
+ if self.use_command_palette and not opened:
1075
+ palette = CommandPalette(placeholder=_(_PALETTE_PLACEHOLDER), id="--command-palette")
1076
+ self.push_screen(palette)
1077
+
1078
+ @property
1079
+ def active_conversation(self) -> str | None:
1080
+ """JID активной беседы. None, пока беседа не выбрана."""
1081
+ return self._active
1082
+
1083
+ def roster_entries(self) -> tuple[RosterEntry, ...]:
1084
+ """Контакты для палитры перехода, отсортированные по серверу.
1085
+
1086
+ Берется контакт-лист и открытые беседы, которых в нем нет: комната в
1087
+ roster не попадает, а перейти в нее нужно так же. Данные о непрочитанном
1088
+ и о комнате есть только у беседы, присутствие и подписка - только у
1089
+ записи контакт-листа, поэтому источники объединяются по JID.
1090
+ """
1091
+ opened = {item.jid: item for item in self._conversations}
1092
+ entries = [
1093
+ RosterEntry(
1094
+ jid=item.jid,
1095
+ title=item.display_name,
1096
+ show=item.show,
1097
+ is_muc=opened[item.jid].is_muc if item.jid in opened else False,
1098
+ unread=opened[item.jid].unread if item.jid in opened else 0,
1099
+ subscription=item.subscription,
1100
+ status=item.status,
1101
+ )
1102
+ for item in self._roster
1103
+ ]
1104
+ known = {item.jid for item in self._roster}
1105
+ entries.extend(
1106
+ RosterEntry(
1107
+ jid=item.jid,
1108
+ title=item.display_title,
1109
+ show=item.show,
1110
+ is_muc=item.is_muc,
1111
+ unread=item.unread,
1112
+ )
1113
+ for item in self._conversations
1114
+ if item.jid not in known
1115
+ )
1116
+ entries.extend(self._occupant_entries())
1117
+ return tuple(sorted(entries, key=_entry_order))
1118
+
1119
+ def _occupant_entries(self) -> list[RosterEntry]:
1120
+ """Участники активной комнаты отдельными записями палитры.
1121
+
1122
+ Отдельной панели участников нет намеренно: на десяти строках она отняла
1123
+ бы место у ленты, а вопрос "кто здесь" решается тем же Ctrl+O, что и
1124
+ переход по беседам. Число участников видно в заголовке беседы.
1125
+ """
1126
+ active = self._active
1127
+ if active is None:
1128
+ return []
1129
+ nicks = self._occupants.get(active, ())
1130
+ if not nicks:
1131
+ return []
1132
+ return [
1133
+ RosterEntry(
1134
+ jid=f"{active}/{nick}",
1135
+ title=nick,
1136
+ show=PresenceShow.AVAILABLE,
1137
+ is_muc=False,
1138
+ unread=0,
1139
+ status=_(_OCCUPANT_HINT),
1140
+ )
1141
+ for nick in nicks
1142
+ ]
1143
+
1144
+ def action_roster_palette(self) -> None:
1145
+ """Ctrl+O - палитра контактов с переходом в выбранную беседу."""
1146
+ if CommandPalette.is_open(self.app):
1147
+ return
1148
+ if not self.roster_entries():
1149
+ self._notice(_("roster is empty, no conversations open"), NoticeLevel.WARNING)
1150
+ return
1151
+ palette = CommandPalette(
1152
+ providers=[RosterProvider],
1153
+ placeholder=_(_ROSTER_PLACEHOLDER),
1154
+ id="--roster-palette",
1155
+ )
1156
+ self.push_screen(palette)
1157
+
1158
+ def open_from_palette(self, jid: str) -> None:
1159
+ """Открыть беседу, выбранную в палитре контактов."""
1160
+ self._bus.dispatch(OpenConversation(jid))
1161
+
1162
+ def run_palette_command(self, spec: commands.CommandSpec) -> None:
1163
+ """Выполнить команду, выбранную в палитре.
1164
+
1165
+ Команда без аргументов выполняется сразу, команда с аргументами
1166
+ подставляется в строку ввода: палитра не умеет спрашивать параметры.
1167
+ Выход из палитры проходит то же подтверждение, что и ctrl+c: выбрать
1168
+ /quit нечетким поиском слишком легко.
1169
+ """
1170
+ if spec.handler_key == "sys.quit":
1171
+ self.action_request_quit()
1172
+ return
1173
+ if spec.min_args == 0 and not spec.subcommands:
1174
+ self.submit_line(f"/{spec.name}")
1175
+ else:
1176
+ self.prefill_prompt(f"/{spec.name} ")
1177
+
1178
+ def prefill_prompt(self, text: str) -> None:
1179
+ """Подставить заготовку в строку ввода и перевести туда фокус."""
1180
+ field = self._find("#prompt-input", Input)
1181
+ if field is None:
1182
+ return
1183
+ field.value = text
1184
+ field.cursor_position = len(text)
1185
+ field.focus()
1186
+
1187
+ # -- локальные команды ---------------------------------------------------
1188
+
1189
+ def _cmd_help(self, parsed: commands.ParsedCommand) -> None:
1190
+ """/help [команда] - справка из реестра команд."""
1191
+ self._notice(commands.help_text(parsed.sub_args[0] if parsed.sub_args else None))
1192
+
1193
+ def _cmd_keys(self, parsed: commands.ParsedCommand) -> None:
1194
+ """/keys - таблицы клавиш, собранные из фактических привязок.
1195
+
1196
+ Девять одинаковых строк alt+1..alt+9 схлопываются в одну: они об одном
1197
+ действии. Клавиши строки ввода и панели лога объявлены не в BINDINGS
1198
+ приложения, поэтому идут отдельными таблицами: одна и та же клавиша в
1199
+ разных областях делает разное, и без заголовка это не читается.
1200
+ """
1201
+ del parsed
1202
+ rows: list[tuple[str, str]] = [
1203
+ (binding.key, _(binding.description))
1204
+ for binding in self.BINDINGS
1205
+ if isinstance(binding, Binding)
1206
+ and binding.description
1207
+ and binding.key not in _ALT_NUMBER_KEYS
1208
+ ]
1209
+ rows.append(("alt+1..alt+9", _("conversation by number")))
1210
+ self._block(_("application keys"), rows)
1211
+ self._block(_("input line"), [(key, _(text)) for key, text in _EXTERNAL_KEYS])
1212
+ self._block(_("RAW XML panel"), [(key, _(text)) for key, text in _PANEL_KEYS])
1213
+ self._block(_("marks in the conversation feed"), list(xeps.compact_legend()))
1214
+
1215
+ def _cmd_theme(self, parsed: commands.ParsedCommand) -> None:
1216
+ """/theme <имя> - сменить тему Textual."""
1217
+ available = sorted(self.available_themes)
1218
+ names = ", ".join(available)
1219
+ if not parsed.sub_args:
1220
+ self._notice(_("current theme: {name}").format(name=self.theme))
1221
+ self._notice(_("available themes: {names}").format(names=names))
1222
+ return
1223
+ name = parsed.sub_args[0]
1224
+ if name not in self.available_themes:
1225
+ self._notice(_("no theme {name!r}").format(name=name), NoticeLevel.ERROR)
1226
+ self._notice(_("available themes: {names}").format(names=names), NoticeLevel.ERROR)
1227
+ return
1228
+ self.theme = name
1229
+ self._notice(_("theme: {name}").format(name=name), NoticeLevel.SUCCESS)
1230
+
1231
+ def _cmd_lang(self, parsed: commands.ParsedCommand) -> None:
1232
+ """/lang [en|ru] - показать или сменить язык интерфейса.
1233
+
1234
+ Язык меняется до выхода из клиента, постоянный выбор задается в конфиге.
1235
+ Переводятся подписи панелей и все новые строки. Уже выведенные строки
1236
+ ленты остаются на прежнем языке: это история, а не подписи.
1237
+ """
1238
+ if not parsed.sub_args:
1239
+ current = _("interface language: {name}").format(
1240
+ name=_language_name(i18n.get_language())
1241
+ )
1242
+ languages = _("available languages: {languages}").format(
1243
+ languages=", ".join(i18n.LANGUAGES)
1244
+ )
1245
+ self._notice(f"{current}\n{languages}")
1246
+ return
1247
+ value = parsed.sub_args[0]
1248
+ language = i18n.normalize_language(value)
1249
+ if language is None:
1250
+ self._notice(
1251
+ _("language {value!r} not recognized, expected: {usage}").format(
1252
+ value=value, usage=parsed.usage
1253
+ ),
1254
+ NoticeLevel.ERROR,
1255
+ )
1256
+ return
1257
+ i18n.set_language(language)
1258
+ self.apply_language()
1259
+ for panel in (self._chat, self._xmllog, self._prompt):
1260
+ if panel is not None:
1261
+ panel.apply_language()
1262
+ if self._status is not None:
1263
+ self._status.refresh()
1264
+ self.screen.refresh()
1265
+ self._notice(
1266
+ _("interface language: {name}").format(name=_language_name(language)),
1267
+ NoticeLevel.SUCCESS,
1268
+ )
1269
+
1270
+ def _cmd_quit(self, parsed: commands.ParsedCommand) -> None:
1271
+ """/quit - выход без подтверждения: команда введена явно."""
1272
+ del parsed
1273
+ self.exit()
1274
+
1275
+ def _cmd_clear(self, parsed: commands.ParsedCommand) -> None:
1276
+ """/clear - очистить область беседы."""
1277
+ del parsed
1278
+ if self._chat is None:
1279
+ return
1280
+ hidden = self._chat.clear_conversation()
1281
+ self._notice(
1282
+ _("conversation area cleared, entries hidden: {count}").format(count=hidden),
1283
+ NoticeLevel.SUCCESS,
1284
+ )
1285
+
1286
+ def _cmd_send(self, parsed: commands.ParsedCommand) -> None:
1287
+ """/send <raw-xml> - отправить произвольную строфу с подтверждением.
1288
+
1289
+ Подтверждение требует ТЗ и обещает справка: строфа уходит в поток как есть,
1290
+ опечатка в ней разрывает соединение.
1291
+ """
1292
+ payload = " ".join(parsed.args).strip()
1293
+ if not payload:
1294
+ self._notice(_("expected: {usage}").format(usage=parsed.usage), NoticeLevel.ERROR)
1295
+ return
1296
+ self._pending_send = payload
1297
+ question = _(_SEND_QUESTION)
1298
+ # Одно действие - один блок в ленте: три отдельных уведомления давали три
1299
+ # строки времени на один вопрос.
1300
+ self._notice(
1301
+ _("the stanza will be sent to the stream as is:\n {payload}\n{question}").format(
1302
+ payload=payload, question=question
1303
+ ),
1304
+ NoticeLevel.WARNING,
1305
+ )
1306
+ self._await_answer(question)
1307
+
1308
+ def _resolve_send(self, answer: str) -> None:
1309
+ """Обработать ответ на подтверждение отправки строфы."""
1310
+ payload = self._pending_send
1311
+ self._pending_send = None
1312
+ self._clear_answer()
1313
+ if payload is None:
1314
+ return
1315
+ if answer.strip().lower() not in _CONFIRMATIONS:
1316
+ self._notice(_("stanza not sent: {answer}").format(answer=answer), NoticeLevel.SUCCESS)
1317
+ return
1318
+ self._bus.dispatch(SendRawXml(payload))
1319
+
1320
+ def _cmd_debug_panel(self, parsed: commands.ParsedCommand) -> None:
1321
+ """/debug - развернуть панель лога на весь экран."""
1322
+ del parsed
1323
+ self.layout_mode = "debug" if self.layout_mode != "debug" else "split"
1324
+ self._notice(_("layout: {mode}").format(mode=self.layout_mode))
1325
+
1326
+ def _cmd_stats(self, parsed: commands.ParsedCommand) -> None:
1327
+ """/stats - счетчики потока, буфера и очереди команд."""
1328
+ del parsed
1329
+ metrics = self._state.metrics
1330
+ rows: list[tuple[str, str]] = [
1331
+ (_("stanzas total"), str(metrics.stanzas_total)),
1332
+ (_("stanzas per second"), f"{metrics.stanzas_per_sec:.1f}"),
1333
+ (
1334
+ _("traffic"),
1335
+ f"in {humanize_bytes(metrics.bytes_in)} / out {humanize_bytes(metrics.bytes_out)}",
1336
+ ),
1337
+ (_("latency"), format_latency(metrics.latency_ms)),
1338
+ ("RSS", humanize_bytes(metrics.rss_bytes)),
1339
+ (_("session uptime"), humanize_duration(metrics.uptime_s)),
1340
+ (_("process uptime"), humanize_duration(time.monotonic() - self._started)),
1341
+ (_("reconnects"), str(metrics.reconnects)),
1342
+ (_("stage"), stage_label(self._state.stage)),
1343
+ (_("transport"), transport_label(self._state.transport)),
1344
+ (_("commands in queue"), str(self._bus.pending_commands)),
1345
+ ]
1346
+ if self._xmllog is not None:
1347
+ stats = self._xmllog.stats()
1348
+ buffer = _(
1349
+ "{buffered} buffered, {pending} awaiting render, evicted {dropped}, pause={paused}"
1350
+ ).format(
1351
+ buffered=stats.buffered,
1352
+ pending=stats.pending,
1353
+ dropped=stats.dropped,
1354
+ paused=_("yes") if stats.paused else _("no"),
1355
+ )
1356
+ rows.append((_("log buffer"), buffer))
1357
+ self._block(_("statistics"), rows)
1358
+
1359
+ def _cmd_log_save(self, parsed: commands.ParsedCommand) -> None:
1360
+ """/log save <файл> - выгрузить буфер строф с текущим маскированием.
1361
+
1362
+ Снимок буфера и флаг маскирования снимаются здесь, в цикле событий, а сама
1363
+ запись уходит в поток: маскирование двух тысяч строф и запись файла заняли бы
1364
+ десятки миллисекунд и дали бы заметный провал отрисовки под нагрузкой.
1365
+ """
1366
+ if not parsed.sub_args:
1367
+ self._notice(_("expected: {usage}").format(usage=parsed.usage), NoticeLevel.ERROR)
1368
+ return
1369
+ path = Path(parsed.sub_args[0]).expanduser()
1370
+ self.run_worker(
1371
+ partial(self._save_log, path, tuple(self._stanzas), self._unsafe),
1372
+ name="log-save",
1373
+ group="io",
1374
+ thread=True,
1375
+ exit_on_error=False,
1376
+ )
1377
+
1378
+ def _save_log(self, path: Path, snapshot: tuple[RawStanza, ...], unsafe: bool) -> None:
1379
+ """Тело фоновой записи лога. Выполняется вне цикла событий."""
1380
+ try:
1381
+ self._write_log(path, snapshot, unsafe)
1382
+ except OSError as error:
1383
+ self.call_from_thread(
1384
+ self._notice, _("write failed: {error}").format(error=error), NoticeLevel.ERROR
1385
+ )
1386
+ return
1387
+ mode = _("without masking") if unsafe else _("with masking")
1388
+ self.call_from_thread(
1389
+ self._notice,
1390
+ _("stanzas saved: {count}, file {path} ({mode}, permissions 0600)").format(
1391
+ count=len(snapshot), path=path, mode=mode
1392
+ ),
1393
+ NoticeLevel.SUCCESS,
1394
+ )
1395
+
1396
+ def _write_log(self, path: Path, snapshot: tuple[RawStanza, ...], unsafe: bool) -> None:
1397
+ """Записать снимок буфера в файл с правами 0600.
1398
+
1399
+ O_NOFOLLOW обязателен: без него запись идет по символической ссылке, и
1400
+ команда с подставленным путем усекает и перезаписывает чужой файл, а chmod
1401
+ по пути меняет права цели ссылки. С флагом открытие такого пути завершается
1402
+ ошибкой, она уходит в область беседы. Права выставляются по дескриптору
1403
+ через fchmod, а не по пути: путь между открытием и chmod можно подменить.
1404
+ """
1405
+ # Файл сразу создается с правами 0600: писать секреты во временно
1406
+ # доступный всем файл нельзя даже на доли секунды.
1407
+ flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW
1408
+ descriptor = os.open(path, flags, 0o600)
1409
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
1410
+ # Существующий файл мог иметь другие права: O_CREAT их не меняет.
1411
+ os.fchmod(handle.fileno(), 0o600)
1412
+ for stanza in snapshot:
1413
+ stamp = time.strftime("%H:%M:%S", time.localtime(stanza.ts))
1414
+ millis = int(stanza.ts % 1 * 1000)
1415
+ arrow = "OUT" if stanza.direction is Direction.OUT else "IN "
1416
+ handle.write(
1417
+ f"{stamp}.{millis:03d} {arrow} {stanza.kind.value:<8}"
1418
+ f" {redact(stanza.xml, unsafe)}\n"
1419
+ )
1420
+
1421
+ def _cmd_xml_filter(self, parsed: commands.ParsedCommand) -> None:
1422
+ """/xml filter <выражение> - фильтр панели лога.
1423
+
1424
+ Ответ один: панель сама сообщает, что разобрала и сколько строф подошло.
1425
+ Опечатка в поле не применяется, а объясняется перечнем допустимых значений.
1426
+ """
1427
+ self._set_filter(" ".join(parsed.sub_args))
1428
+
1429
+ def _cmd_xml(self, parsed: commands.ParsedCommand) -> None:
1430
+ """/xml [in|out|both|off] [--unsafe] - режим лога и небезопасный показ."""
1431
+ args = parsed.sub_args
1432
+ mode: XmlMode | None = None
1433
+ if args:
1434
+ try:
1435
+ mode = XmlMode(args[0].lower())
1436
+ except ValueError:
1437
+ self._notice(
1438
+ _("mode {mode!r} not recognized, expected: {usage}").format(
1439
+ mode=args[0], usage=parsed.usage
1440
+ ),
1441
+ NoticeLevel.ERROR,
1442
+ )
1443
+ return
1444
+
1445
+ unsafe = parsed.flag_bool("unsafe")
1446
+ if unsafe and not self._unsafe:
1447
+ self._request_unsafe(mode)
1448
+ return
1449
+
1450
+ if mode is not None:
1451
+ self._set_mode(mode)
1452
+ if not unsafe and self._unsafe and mode is not None:
1453
+ # Команда без флага описывает желаемое состояние целиком, поэтому
1454
+ # явная смена режима возвращает маскирование. Отдельного ключа
1455
+ # выключения в реестре нет.
1456
+ self._set_unsafe(False)
1457
+ if mode is None and not unsafe:
1458
+ self._notice(
1459
+ _("log: mode {mode}, filter {filter}, unsafe {unsafe}").format(
1460
+ mode=self._xml_mode.value,
1461
+ filter=self._xml_filter or _("no"),
1462
+ unsafe=_("yes") if self._unsafe else _("no"),
1463
+ )
1464
+ )
1465
+
1466
+ def _request_unsafe(self, mode: XmlMode | None) -> None:
1467
+ """Показать предупреждение и перейти в ожидание подтверждения вводом."""
1468
+ self._awaiting_unsafe = True
1469
+ self._pending_unsafe = mode
1470
+ rules = "\n".join(f" - {rule}" for rule in redaction_summary())
1471
+ question = _(_UNSAFE_QUESTION)
1472
+ self._notice(
1473
+ _(
1474
+ "{warning}\nthe following masking rules will be disabled:\n{rules}\n{question}"
1475
+ ).format(warning=_(UNSAFE_WARNING), rules=rules, question=question),
1476
+ NoticeLevel.WARNING,
1477
+ )
1478
+ self._await_answer(question)
1479
+
1480
+ def _await_answer(self, question: str) -> None:
1481
+ """Перевести строку ввода в режим ожидания ответа.
1482
+
1483
+ Сам вопрос печатает вызывающая сторона вместе с предупреждением одним
1484
+ блоком: иначе на один вопрос приходится несколько строк времени в ленте.
1485
+ """
1486
+ if self._prompt is not None:
1487
+ self._prompt.set_awaiting(question)
1488
+
1489
+ def _clear_answer(self) -> None:
1490
+ """Вернуть строке ввода обычный вид."""
1491
+ if self._prompt is not None:
1492
+ self._prompt.set_awaiting("")
1493
+
1494
+ def _resolve_unsafe(self, answer: str) -> None:
1495
+ """Обработать ответ на запрос подтверждения небезопасного режима."""
1496
+ if answer.strip().lower() not in _CONFIRMATIONS:
1497
+ self._notice(_("cancelled input: {answer}").format(answer=answer), NoticeLevel.INFO)
1498
+ self._cancel_unsafe()
1499
+ return
1500
+ mode = self._pending_unsafe
1501
+ self._awaiting_unsafe = False
1502
+ self._pending_unsafe = None
1503
+ self._clear_answer()
1504
+ if mode is not None:
1505
+ self._set_mode(mode)
1506
+ self._set_unsafe(True)
1507
+ self._notice(_("unsafe mode on, masking disabled"), NoticeLevel.ERROR)
1508
+
1509
+ def _cancel_unsafe(self) -> None:
1510
+ """Отменить ожидание подтверждения."""
1511
+ self._awaiting_unsafe = False
1512
+ self._pending_unsafe = None
1513
+ self._clear_answer()
1514
+ self._notice(_("unsafe mode not enabled, masking kept"), NoticeLevel.SUCCESS)
1515
+
1516
+ # -- применение настроек лога -------------------------------------------
1517
+
1518
+ def _set_mode(self, mode: XmlMode) -> None:
1519
+ """Сменить режим лога: локально и через шину.
1520
+
1521
+ Панель обновляется сразу, чтобы интерфейс не ждал сессию, и одновременно
1522
+ уходит команда: владелец состояния - сессия, она публикует событие
1523
+ для статус-бара и остальных подписчиков. Оба вызова идемпотентны.
1524
+ """
1525
+ self._xml_mode = mode
1526
+ if self._xmllog is not None:
1527
+ self._xmllog.set_mode(mode)
1528
+ self._bus.dispatch(SetXmlMode(mode))
1529
+
1530
+ def _set_filter(self, expression: str) -> None:
1531
+ """Задать фильтр панели лога. Фильтр применяется только в UI."""
1532
+ if self._xmllog is None:
1533
+ self._xml_filter = expression
1534
+ self._bus.dispatch(SetXmlFilter(expression))
1535
+ return
1536
+ result = self._xmllog.set_filter(expression)
1537
+ if not result.ok:
1538
+ self._notice(result.text, NoticeLevel.ERROR)
1539
+ self._notice(_("format: {usage}").format(usage=_(_FILTER_USAGE)), NoticeLevel.ERROR)
1540
+ return
1541
+ self._xml_filter = expression
1542
+ self._bus.dispatch(SetXmlFilter(expression))
1543
+ self._notice(result.text)
1544
+
1545
+ def _set_unsafe(self, enabled: bool) -> None:
1546
+ """Включить или выключить показ без маскирования."""
1547
+ self._unsafe = enabled
1548
+ if self._xmllog is not None:
1549
+ self._xmllog.set_unsafe(enabled)
1550
+ self._mark_unsafe(enabled)
1551
+ self._bus.dispatch(SetUnsafeXml(enabled))
1552
+
1553
+ # -- вывод ---------------------------------------------------------------
1554
+
1555
+ def _notice(self, text: str, level: NoticeLevel = NoticeLevel.INFO) -> None:
1556
+ """Вывести служебный текст в область беседы. Многострочный текст панель сверстает сама."""
1557
+ if self._chat is None or not text:
1558
+ return
1559
+ self._chat.add_notice(text, level)
1560
+
1561
+ def _block(self, title: str, rows: Sequence[tuple[str, str]]) -> None:
1562
+ """Вывести таблицу имя-значение в область беседы."""
1563
+ if self._chat is None:
1564
+ return
1565
+ self._chat.add_block(title, rows)