@emaxe/tuigram 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,355 @@
1
+ import blessed from "neo-blessed";
2
+ import path from "node:path";
3
+ import { inspectLocalFile } from "../../../utils/storage.js";
4
+ import { formatFileSize } from "../../../utils/time.js";
5
+ import { createFilePickerModal } from "./filePickerModal.js";
6
+ import { fg } from "../../theme.js";
7
+
8
+ /** Разделитель нескольких путей в поле ввода. */
9
+ const PATH_SEPARATOR = "|";
10
+
11
+ /**
12
+ * Создаёт модальное окно отправки локальных файлов.
13
+ * @param {blessed.Widgets.Screen} screen
14
+ * @param {object} theme
15
+ * @param {object} callbacks
16
+ * @param {(files: Array<object>, options: { caption: string, asDocument: boolean }) => void} callbacks.onSendFile
17
+ * @returns {{ show: () => void, hide: () => void }}
18
+ */
19
+ export function createFileModal(screen, theme, { onSendFile } = {}) {
20
+ // Высота 14 = рамка (2) + 12 внутренних строк под поля, чекбокс,
21
+ // строку состояния, кнопки и подсказку.
22
+ const modal = blessed.box({
23
+ parent: screen,
24
+ top: "center",
25
+ left: "center",
26
+ width: "60%",
27
+ height: 14,
28
+ hidden: true,
29
+ tags: true,
30
+ border: {
31
+ type: "line",
32
+ },
33
+ shadow: true,
34
+ style: {
35
+ bg: theme.modal.bg,
36
+ fg: theme.modal.fg,
37
+ border: {
38
+ fg: theme.modal.borderFg,
39
+ },
40
+ },
41
+ });
42
+
43
+ blessed.box({
44
+ parent: modal,
45
+ top: 0,
46
+ left: 1,
47
+ right: 1,
48
+ height: 1,
49
+ tags: true,
50
+ content: " {bold}📤 Отправка файла или документа{/bold}",
51
+ style: { bg: theme.modal.bg, fg: theme.modal.fg },
52
+ });
53
+
54
+ blessed.box({
55
+ parent: modal,
56
+ top: 2,
57
+ left: 2,
58
+ right: 2,
59
+ height: 1,
60
+ tags: true,
61
+ content: `Путь к файлу ${fg(theme.modal.hintFg, `(несколько — через ${PATH_SEPARATOR})`)}{|}${fg(theme.accent, "[Ctrl+F] Обзор")} `,
62
+ style: { bg: theme.modal.bg, fg: theme.modal.fg },
63
+ });
64
+
65
+ const pathInput = blessed.textbox({
66
+ parent: modal,
67
+ top: 3,
68
+ left: 2,
69
+ right: 2,
70
+ height: 1,
71
+ inputOnFocus: true,
72
+ style: {
73
+ bg: theme.modal.inputBg,
74
+ fg: theme.modal.inputFg,
75
+ focus: { bg: theme.modal.inputFocusBg, fg: theme.modal.inputFocusFg },
76
+ },
77
+ });
78
+
79
+ blessed.box({
80
+ parent: modal,
81
+ top: 5,
82
+ left: 2,
83
+ right: 2,
84
+ height: 1,
85
+ content: "Подпись (необязательно):",
86
+ style: { bg: theme.modal.bg, fg: theme.modal.fg },
87
+ });
88
+
89
+ const captionInput = blessed.textbox({
90
+ parent: modal,
91
+ top: 6,
92
+ left: 2,
93
+ right: 2,
94
+ height: 1,
95
+ inputOnFocus: true,
96
+ style: {
97
+ bg: theme.modal.inputBg,
98
+ fg: theme.modal.inputFg,
99
+ focus: { bg: theme.modal.inputFocusBg, fg: theme.modal.inputFocusFg },
100
+ },
101
+ });
102
+
103
+ const asDocumentCheck = blessed.checkbox({
104
+ parent: modal,
105
+ top: 8,
106
+ left: 2,
107
+ right: 2,
108
+ height: 1,
109
+ mouse: true,
110
+ text: "Как файл, без сжатия [Ctrl+D]",
111
+ style: {
112
+ bg: theme.modal.bg,
113
+ fg: theme.modal.fg,
114
+ focus: { bg: theme.modal.selectedBg, fg: theme.modal.selectedFg, bold: true },
115
+ },
116
+ });
117
+
118
+ const statusLine = blessed.box({
119
+ parent: modal,
120
+ top: 9,
121
+ left: 2,
122
+ right: 2,
123
+ height: 1,
124
+ tags: true,
125
+ style: { bg: theme.modal.bg, fg: theme.modal.fg },
126
+ });
127
+
128
+ const sendBtn = blessed.button({
129
+ parent: modal,
130
+ bottom: 1,
131
+ left: 4,
132
+ width: 14,
133
+ height: 1,
134
+ mouse: true,
135
+ content: " [ Отправить ] ",
136
+ align: "center",
137
+ style: {
138
+ bg: theme.modal.buttonBg,
139
+ fg: theme.modal.buttonFg,
140
+ focus: { bg: theme.modal.buttonFocusBg, fg: theme.modal.buttonFocusFg, bold: true },
141
+ },
142
+ });
143
+
144
+ const cancelBtn = blessed.button({
145
+ parent: modal,
146
+ bottom: 1,
147
+ right: 4,
148
+ width: 14,
149
+ height: 1,
150
+ mouse: true,
151
+ content: " [ Отмена ] ",
152
+ align: "center",
153
+ style: {
154
+ bg: theme.modal.dangerBg,
155
+ fg: theme.modal.dangerFg,
156
+ focus: { bg: theme.modal.buttonFocusBg, fg: theme.modal.buttonFocusFg, bold: true },
157
+ },
158
+ });
159
+
160
+ blessed.box({
161
+ parent: modal,
162
+ bottom: 0,
163
+ left: 2,
164
+ right: 2,
165
+ height: 1,
166
+ tags: true,
167
+ align: "center",
168
+ content: fg(theme.modal.hintFg, "[Tab] Поля [Enter] Далее [Ctrl+F] Обзор [Ctrl+D] Без сжатия [Esc] Выход"),
169
+ style: { bg: theme.modal.bg, fg: theme.modal.fg },
170
+ });
171
+
172
+ const picker = createFilePickerModal(screen, theme);
173
+
174
+ let previousFocus = null;
175
+ let lastDir = null;
176
+
177
+ const KIND_LABEL = { photo: "уйдёт как фото", video: "уйдёт как видео", document: "уйдёт как документ" };
178
+
179
+ /** Разбирает поле пути в список введённых путей. */
180
+ function currentPaths() {
181
+ return pathInput
182
+ .getValue()
183
+ .split(PATH_SEPARATOR)
184
+ .map((part) => part.trim())
185
+ .filter(Boolean);
186
+ }
187
+
188
+ function setError(text) {
189
+ statusLine.setContent(fg(theme.error, `✕ ${text}`));
190
+ screen.render();
191
+ }
192
+
193
+ /**
194
+ * Проверяет введённые пути и обновляет строку состояния.
195
+ * Значение поля НИКОГДА не перезаписывается — ошибка живёт отдельно.
196
+ * @returns {Array<object>|null} валидные файлы либо null при ошибке
197
+ */
198
+ function validate({ quiet = false } = {}) {
199
+ const raw = currentPaths();
200
+ if (raw.length === 0) {
201
+ statusLine.setContent("");
202
+ screen.render();
203
+ return null;
204
+ }
205
+
206
+ const files = [];
207
+ for (const entry of raw) {
208
+ const info = inspectLocalFile(entry);
209
+ if (!info.ok) {
210
+ if (!quiet) setError(info.error);
211
+ return null;
212
+ }
213
+ files.push(info);
214
+ }
215
+
216
+ lastDir = path.dirname(files[files.length - 1].filePath);
217
+
218
+ const asDocument = Boolean(asDocumentCheck.checked);
219
+ const totalSize = files.reduce((sum, f) => sum + f.size, 0);
220
+
221
+ if (files.length === 1) {
222
+ const [file] = files;
223
+ const kind = asDocument ? "уйдёт как файл" : KIND_LABEL[file.kind];
224
+ statusLine.setContent(fg(theme.success, `✓ ${file.name} · ${formatFileSize(file.size)} · ${kind}`));
225
+ } else {
226
+ statusLine.setContent(
227
+ fg(theme.success, `✓ Альбом из ${files.length} файлов · ${formatFileSize(totalSize)}`)
228
+ );
229
+ }
230
+ screen.render();
231
+ return files;
232
+ }
233
+
234
+ function hide() {
235
+ picker.hide();
236
+ modal.hide();
237
+ if (previousFocus) {
238
+ previousFocus.focus();
239
+ previousFocus = null;
240
+ }
241
+ screen.render();
242
+ }
243
+
244
+ function show() {
245
+ pathInput.setValue("");
246
+ captionInput.setValue("");
247
+ asDocumentCheck.uncheck();
248
+ statusLine.setContent("");
249
+ previousFocus = screen.focused;
250
+ modal.show();
251
+ modal.setFront();
252
+ pathInput.focus();
253
+ screen.render();
254
+ }
255
+
256
+ function submit() {
257
+ const files = validate();
258
+ if (!files) {
259
+ if (currentPaths().length === 0) setError("Укажите путь к файлу или нажмите Ctrl+F");
260
+ pathInput.focus();
261
+ return;
262
+ }
263
+
264
+ const caption = captionInput.getValue().trim();
265
+ const asDocument = Boolean(asDocumentCheck.checked);
266
+ hide();
267
+ onSendFile?.(files, { caption, asDocument });
268
+ }
269
+
270
+ /** Завершает режим чтения textbox — иначе он заберёт фокус обратно по blur. */
271
+ function releaseInputs() {
272
+ for (const el of [pathInput, captionInput]) {
273
+ if (el._reading && typeof el._done === "function") el._done("stop");
274
+ }
275
+ }
276
+
277
+ function openPicker() {
278
+ releaseInputs();
279
+ picker.pick(
280
+ lastDir,
281
+ (filePath) => {
282
+ const existing = currentPaths();
283
+ existing.push(filePath);
284
+ pathInput.setValue(existing.join(` ${PATH_SEPARATOR} `));
285
+ validate();
286
+ },
287
+ () => {
288
+ // И после выбора, и после отмены возвращаемся в поле пути
289
+ modal.setFront();
290
+ pathInput.focus();
291
+ screen.render();
292
+ }
293
+ );
294
+ }
295
+
296
+ function toggleAsDocument() {
297
+ asDocumentCheck.toggle();
298
+ validate({ quiet: true });
299
+ screen.render();
300
+ }
301
+
302
+ // --- Кольцо фокуса ---
303
+ const RING = [pathInput, captionInput, asDocumentCheck, sendBtn, cancelBtn];
304
+
305
+ function moveFocus(step) {
306
+ // blessed вставляет "\t" в значение textbox ДО вызова обработчика
307
+ for (const el of [pathInput, captionInput]) {
308
+ if (typeof el.getValue === "function") {
309
+ const cleaned = el.getValue().replace(/\t+$/, "");
310
+ if (cleaned !== el.getValue()) el.setValue(cleaned);
311
+ }
312
+ }
313
+ const current = RING.indexOf(screen.focused);
314
+ const index = current === -1 ? 0 : current;
315
+ releaseInputs();
316
+ RING[(index + step + RING.length) % RING.length].focus();
317
+ screen.render();
318
+ }
319
+
320
+ for (const el of RING) {
321
+ el.key(["tab"], () => moveFocus(1));
322
+ el.key(["S-tab"], () => moveFocus(-1));
323
+ el.key(["C-f"], openPicker);
324
+ el.key(["C-d"], toggleAsDocument);
325
+ }
326
+
327
+ // Escape: у textbox он приходит событием "cancel", у остальных — клавишей
328
+ pathInput.on("cancel", hide);
329
+ captionInput.on("cancel", hide);
330
+ asDocumentCheck.key(["escape"], hide);
331
+ sendBtn.key(["escape"], hide);
332
+ cancelBtn.key(["escape"], hide);
333
+
334
+ // Enter ведёт по цепочке, с последнего поля — отправляет
335
+ pathInput.on("submit", () => {
336
+ validate();
337
+ captionInput.focus();
338
+ });
339
+ captionInput.on("submit", submit);
340
+ sendBtn.on("press", submit);
341
+ cancelBtn.on("press", hide);
342
+ asDocumentCheck.on("check", () => validate({ quiet: true }));
343
+ asDocumentCheck.on("uncheck", () => validate({ quiet: true }));
344
+
345
+ return {
346
+ modal,
347
+ pathInput,
348
+ captionInput,
349
+ asDocumentCheck,
350
+ statusLine,
351
+ picker,
352
+ show,
353
+ hide,
354
+ };
355
+ }
@@ -0,0 +1,214 @@
1
+ import blessed from "neo-blessed";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { formatFileSize } from "../../../utils/time.js";
6
+ import { fg } from "../../theme.js";
7
+
8
+ /**
9
+ * Модальное окно выбора локального файла (обёртка над blessed.filemanager).
10
+ *
11
+ * Клавиши вешаются на сам список: blessed рассылает события только
12
+ * сфокусированному элементу, обработчики на контейнере не срабатывают.
13
+ * @param {blessed.Widgets.Screen} screen
14
+ * @param {object} theme
15
+ * @returns {{ modal: object, pick: (startDir: string|null, onPick: (filePath: string) => void) => void, hide: () => void }}
16
+ */
17
+ export function createFilePickerModal(screen, theme) {
18
+ const modal = blessed.box({
19
+ parent: screen,
20
+ top: "center",
21
+ left: "center",
22
+ width: "60%",
23
+ height: "60%",
24
+ hidden: true,
25
+ tags: true,
26
+ border: {
27
+ type: "line",
28
+ },
29
+ shadow: true,
30
+ style: {
31
+ bg: theme.modal.bg,
32
+ fg: theme.modal.fg,
33
+ border: {
34
+ fg: theme.modal.borderFg,
35
+ },
36
+ },
37
+ });
38
+
39
+ const header = blessed.box({
40
+ parent: modal,
41
+ top: 0,
42
+ left: 1,
43
+ right: 1,
44
+ height: 1,
45
+ tags: true,
46
+ style: { bg: theme.modal.bg, fg: theme.modal.fg },
47
+ });
48
+
49
+ const manager = blessed.filemanager({
50
+ parent: modal,
51
+ top: 1,
52
+ left: 1,
53
+ right: 1,
54
+ bottom: 2,
55
+ keys: true,
56
+ vi: true,
57
+ mouse: true,
58
+ cwd: os.homedir(),
59
+ scrollbar: {
60
+ ch: "│",
61
+ style: { bg: theme.scrollbar.bg, fg: theme.scrollbar.fg },
62
+ },
63
+ style: {
64
+ bg: theme.modal.bg,
65
+ fg: theme.modal.fg,
66
+ selected: {
67
+ bg: theme.modal.selectedBg,
68
+ fg: theme.modal.selectedFg,
69
+ bold: true,
70
+ },
71
+ },
72
+ });
73
+
74
+ const footer = blessed.box({
75
+ parent: modal,
76
+ bottom: 0,
77
+ left: 1,
78
+ right: 1,
79
+ height: 2,
80
+ tags: true,
81
+ style: { bg: theme.modal.bg, fg: theme.modal.fg },
82
+ });
83
+
84
+ let onPickCallback = null;
85
+ let onCloseCallback = null;
86
+
87
+ function renderHeader() {
88
+ const cwd = manager.cwd.replace(os.homedir(), "~");
89
+ header.setContent(` {bold}📁 Выбор файла:{/bold} ${cwd}`);
90
+ }
91
+
92
+ /**
93
+ * Показывает размер подсвеченного файла — filemanager сам этого не умеет.
94
+ * Имя берём из ritems (исходная строка с тегами), а не из getContent():
95
+ * последний уже содержит ANSI-последовательности, из которых имя не вычленить.
96
+ * @param {number} index
97
+ */
98
+ function renderFooter(index) {
99
+ const hint = fg(theme.modal.hintFg, "[↑↓] Выбор [Enter] Открыть/Выбрать [Esc] Отмена");
100
+ const raw = manager.ritems?.[index];
101
+ let info = "";
102
+
103
+ if (raw) {
104
+ const name = String(raw).replace(/\{[^{}]+\}/g, "").replace(/[@/]$/, "");
105
+ if (name && name !== "..") {
106
+ try {
107
+ const stat = fs.statSync(path.resolve(manager.cwd, name));
108
+ info = stat.isDirectory()
109
+ ? fg(theme.picker.dirFg, `${name}/ — папка`)
110
+ : fg(theme.success, `${name} · ${formatFileSize(stat.size)}`);
111
+ } catch {
112
+ info = fg(theme.error, `${name} — недоступен`);
113
+ }
114
+ }
115
+ }
116
+ footer.setContent(` ${info}\n ${hint}`);
117
+ }
118
+
119
+ function hide() {
120
+ if (modal.hidden) return;
121
+ modal.hide();
122
+ onPickCallback = null;
123
+ // Куда вернуть фокус, решает вызывающая сторона: собственный previousFocus
124
+ // здесь ненадёжен, потому что textbox по blur успевает сделать rewindFocus.
125
+ const close = onCloseCallback;
126
+ onCloseCallback = null;
127
+ close?.();
128
+ screen.render();
129
+ }
130
+
131
+ /**
132
+ * Перекрашивает элементы под тему.
133
+ *
134
+ * blessed.filemanager жёстко вписывает {light-blue-fg} для папок и
135
+ * {light-cyan-fg} для симлинков — на фоне модалки такие имена нечитаемы.
136
+ * Формат "имя" + "/" или "@" сохраняем: по нему виджет находит файл обратно.
137
+ */
138
+ function recolorItems() {
139
+ const source = manager.ritems.slice();
140
+ if (source.length === 0) return;
141
+
142
+ const selected = manager.selected;
143
+ const painted = source.map((raw) => {
144
+ const bare = String(raw).replace(/\{[^{}]+\}/g, "");
145
+ if (bare.endsWith("/")) {
146
+ return `${fg(theme.picker.dirFg, bare.slice(0, -1))}/`;
147
+ }
148
+ if (bare.endsWith("@")) {
149
+ return `${fg(theme.picker.linkFg, bare.slice(0, -1))}@`;
150
+ }
151
+ return fg(theme.picker.fileFg, bare);
152
+ });
153
+
154
+ if (painted.every((text, i) => text === source[i])) return;
155
+ manager.setItems(painted);
156
+ manager.select(Math.min(selected, painted.length - 1));
157
+ }
158
+
159
+ manager.on("refresh", recolorItems);
160
+
161
+ manager.on("select item", (item, index) => {
162
+ renderFooter(index);
163
+ screen.render();
164
+ });
165
+
166
+ manager.on("cd", () => {
167
+ renderHeader();
168
+ renderFooter(-1);
169
+ screen.render();
170
+ });
171
+
172
+ manager.on("file", (filePath) => {
173
+ // Сначала отдаём результат, потом закрываем: onClose возвращает фокус,
174
+ // и вызывающая сторона уже видит проставленное значение.
175
+ const cb = onPickCallback;
176
+ onPickCallback = null;
177
+ cb?.(filePath);
178
+ hide();
179
+ });
180
+
181
+
182
+ manager.on("error", () => {
183
+ renderFooter(-1);
184
+ screen.render();
185
+ });
186
+
187
+ // filemanager сам отдаёт "cancel" по Escape (list.js), но у него нет hide()
188
+ manager.key(["escape", "q"], hide);
189
+ manager.on("cancel", hide);
190
+
191
+ return {
192
+ modal,
193
+ manager,
194
+ /**
195
+ * @param {string|null} startDir каталог, с которого начать
196
+ * @param {(filePath: string) => void} onPick вызывается при выборе файла
197
+ * @param {() => void} [onClose] вызывается после закрытия — и при выборе, и при отмене
198
+ */
199
+ pick: (startDir, onPick, onClose) => {
200
+ onPickCallback = onPick;
201
+ onCloseCallback = onClose;
202
+ const cwd = startDir && fs.existsSync(startDir) ? startDir : os.homedir();
203
+ modal.show();
204
+ modal.setFront();
205
+ manager.refresh(cwd, () => {
206
+ renderHeader();
207
+ renderFooter(manager.selected);
208
+ manager.focus();
209
+ screen.render();
210
+ });
211
+ },
212
+ hide,
213
+ };
214
+ }
@@ -0,0 +1,131 @@
1
+ import blessed from "neo-blessed";
2
+
3
+ /**
4
+ * Создаёт модальное окно справки по всем горячим клавишам и возможностям.
5
+ * @param {blessed.Widgets.Screen} screen
6
+ * @param {object} theme
7
+ * @returns {{ show: () => void, hide: () => void }}
8
+ */
9
+ export function createHelpModal(screen, theme) {
10
+ const modal = blessed.box({
11
+ parent: screen,
12
+ top: "center",
13
+ left: "center",
14
+ width: "70%",
15
+ height: "80%",
16
+ hidden: true,
17
+ tags: true,
18
+ border: {
19
+ type: "line",
20
+ },
21
+ shadow: true,
22
+ style: {
23
+ bg: theme.modal.bg,
24
+ fg: theme.modal.fg,
25
+ border: {
26
+ fg: theme.modal.borderFg,
27
+ },
28
+ },
29
+ });
30
+
31
+ const K_CYAN = `{${theme.accent}-fg}`;
32
+ const K_GREEN = `{${theme.success}-fg}`;
33
+ const K_YELLOW = `{${theme.warning}-fg}`;
34
+ const K_MAGENTA = `{${theme.info}-fg}`;
35
+ const K_RED = `{${theme.error}-fg}`;
36
+ const K_GRAY = `{${theme.modal.hintFg}-fg}`;
37
+ const K_END = "{/}";
38
+
39
+ const content = `
40
+ {bold}{underline}🚀 TuiGram — Горячие клавиши и управление{/underline}{/bold}
41
+
42
+ {bold}Навигация и фокус:{/bold}
43
+ ${K_CYAN}[Tab]${K_END} / ${K_CYAN}[Shift+Tab]${K_END} Фокус по кругу: Список чатов → Лента сообщений → Ввод
44
+ ${K_CYAN}[↑] / [↓]${K_END} Перемещение по списку чатов
45
+ ${K_CYAN}[Enter]${K_END} Открыть выбранный чат / Загрузить историю
46
+ ${K_CYAN}[PageUp] / [Ctrl+U]${K_END} Прокрутка сообщений вверх / Подгрузка старой истории
47
+ ${K_CYAN}[PageDown] / [Ctrl+D]${K_END}Прокрутка сообщений вниз
48
+
49
+ {bold}Вкладки фильтрации диалогов (нажмите цифру в списке чатов):{/bold}
50
+ ${K_YELLOW}[1]${K_END} Все чаты ${K_YELLOW}[2]${K_END} Личные (ЛС) ${K_YELLOW}[3]${K_END} Группы
51
+ ${K_YELLOW}[4]${K_END} Каналы ${K_YELLOW}[5]${K_END} Боты ${K_YELLOW}[6]${K_END} Непрочитанные
52
+ ${K_YELLOW}[/]${K_END} Поиск чатов по названию/username
53
+
54
+ {bold}Работа с сообщениями:{/bold}
55
+ ${K_GREEN}[Enter]${K_END} Отправить набранный текст
56
+ ${K_GREEN}[Ctrl+J]${K_END} Перенос строки без отправки
57
+ ${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на последнее сообщение
58
+ ${K_GREEN}[Ctrl+E]${K_END} Редактировать своё последнее сообщение
59
+ ${K_GREEN}[Ctrl+A]${K_END} Контекстное меню действий (Реакции, Удаление, Скачивание)
60
+ ${K_GREEN}[Ctrl+O]${K_END} Отправить файл / картинку / документ
61
+ ${K_GREEN} [Ctrl+F]${K_END} — в окне отправки: обзор файлов
62
+ ${K_GREEN} [Ctrl+D]${K_END} — в окне отправки: послать без сжатия, файлом
63
+ ${K_GREEN}[Ctrl+P]${K_END} Информация о текущем чате (ID, участники, ссылки)
64
+ ${K_GREEN}[Esc]${K_END} Сбросить режим ответа / редактирования / закрыть окно
65
+
66
+ {bold}Слэш-команды в поле ввода:{/bold}
67
+ ${K_MAGENTA}/help${K_END} Показать данную справку
68
+ ${K_MAGENTA}/info${K_END} Сведения о текущем чате
69
+ ${K_MAGENTA}/sendfile${K_END} Открыть окно отправки файла
70
+ ${K_MAGENTA}/sendfile <путь>${K_END} Отправить файл (поддерживает ~ и пути с пробелами)
71
+ ${K_MAGENTA}/sendfile a | b -- текст${K_END}
72
+ Альбом из нескольких файлов с подписью
73
+ ${K_MAGENTA}/clear${K_END} Очистить историю сообщений на экране
74
+ ${K_MAGENTA}/logout${K_END} Выйти из аккаунта
75
+
76
+ {bold}Выход:{/bold}
77
+ ${K_RED}[Ctrl+Q]${K_END} или ${K_RED}[Ctrl+C]${K_END} Безопасный выход из клиента
78
+ `;
79
+
80
+ modal.setContent(content);
81
+
82
+ const closeBtn = blessed.button({
83
+ parent: modal,
84
+ bottom: 1,
85
+ left: "center",
86
+ width: 16,
87
+ height: 1,
88
+ mouse: true,
89
+ content: " [ Закрыть ] ",
90
+ align: "center",
91
+ tags: true,
92
+ style: {
93
+ bg: theme.accent,
94
+ fg: theme.onAccent,
95
+ focus: {
96
+ bg: theme.modal.buttonFocusBg,
97
+ fg: theme.modal.buttonFocusFg,
98
+ bold: true,
99
+ },
100
+ },
101
+ });
102
+
103
+ let previousFocus = null;
104
+
105
+ function hide() {
106
+ modal.hide();
107
+ if (previousFocus) {
108
+ previousFocus.focus();
109
+ previousFocus = null;
110
+ }
111
+ screen.render();
112
+ }
113
+
114
+ function show() {
115
+ previousFocus = screen.focused;
116
+ modal.show();
117
+ modal.setFront();
118
+ closeBtn.focus();
119
+ screen.render();
120
+ }
121
+
122
+ closeBtn.on("press", hide);
123
+ // Клавиши вешаем на кнопку: blessed отдаёт события только сфокусированному элементу.
124
+ closeBtn.key(["escape", "q", "f1"], hide);
125
+
126
+ return {
127
+ modal,
128
+ show,
129
+ hide,
130
+ };
131
+ }