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.
- termisations/__init__.py +21 -0
- termisations/__main__.py +12 -0
- termisations/app.py +1565 -0
- termisations/app.tcss +183 -0
- termisations/cli.py +1101 -0
- termisations/core/__init__.py +7 -0
- termisations/core/colors.py +74 -0
- termisations/core/commands.py +1508 -0
- termisations/core/events.py +573 -0
- termisations/core/i18n.py +189 -0
- termisations/core/inputlog.py +285 -0
- termisations/core/metrics.py +223 -0
- termisations/core/models.py +498 -0
- termisations/core/paths.py +214 -0
- termisations/core/profile.py +422 -0
- termisations/core/redact.py +530 -0
- termisations/core/router.py +820 -0
- termisations/core/session.py +59 -0
- termisations/core/storage.py +639 -0
- termisations/core/trace.py +182 -0
- termisations/layouts.py +13 -0
- termisations/locale/ru/app.toml +90 -0
- termisations/locale/ru/cli.toml +79 -0
- termisations/locale/ru/core.commands.toml +109 -0
- termisations/locale/ru/core.events.toml +6 -0
- termisations/locale/ru/core.inputlog.toml +6 -0
- termisations/locale/ru/core.metrics.toml +5 -0
- termisations/locale/ru/core.models.toml +9 -0
- termisations/locale/ru/core.paths.toml +7 -0
- termisations/locale/ru/core.profile.toml +22 -0
- termisations/locale/ru/core.redact.toml +12 -0
- termisations/locale/ru/core.router.toml +17 -0
- termisations/locale/ru/core.storage.toml +5 -0
- termisations/locale/ru/core.trace.toml +7 -0
- termisations/locale/ru/mock.toml +164 -0
- termisations/locale/ru/protocol.account.toml +15 -0
- termisations/locale/ru/protocol.caps.toml +4 -0
- termisations/locale/ru/protocol.omemo.toml +6 -0
- termisations/locale/ru/protocol.omemo_storage.toml +7 -0
- termisations/locale/ru/protocol.sasl2.plugin.toml +12 -0
- termisations/locale/ru/protocol.session.toml +208 -0
- termisations/locale/ru/ui.chat.toml +16 -0
- termisations/locale/ru/ui.prompt.toml +22 -0
- termisations/locale/ru/ui.xmllog.toml +22 -0
- termisations/locale/ru/xeps.toml +11 -0
- termisations/mock.py +3682 -0
- termisations/protocol/__init__.py +6 -0
- termisations/protocol/account.py +193 -0
- termisations/protocol/caps.py +192 -0
- termisations/protocol/mam.py +159 -0
- termisations/protocol/omemo.py +164 -0
- termisations/protocol/omemo_storage.py +82 -0
- termisations/protocol/sasl2/__init__.py +17 -0
- termisations/protocol/sasl2/plugin.py +268 -0
- termisations/protocol/sasl2/stanza.py +231 -0
- termisations/protocol/session.py +3501 -0
- termisations/protocol/tracing.py +250 -0
- termisations/protocol/transport.py +175 -0
- termisations/ui/__init__.py +9 -0
- termisations/ui/chat.py +1213 -0
- termisations/ui/prompt.py +920 -0
- termisations/ui/statusbar.py +530 -0
- termisations/ui/xmllog.py +1275 -0
- termisations/xeps.py +564 -0
- termisations-0.1.0.dist-info/METADATA +698 -0
- termisations-0.1.0.dist-info/RECORD +69 -0
- termisations-0.1.0.dist-info/WHEEL +4 -0
- termisations-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|