@emaxe/tuigram 1.2.0 → 1.4.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.
- package/CHANGELOG.md +61 -1
- package/CHANGELOG.ru.md +65 -1
- package/README.md +63 -4
- package/README.ru.md +64 -4
- package/package.json +1 -1
- package/src/telegram/messages.js +98 -19
- package/src/ui/app.js +189 -21
- package/src/ui/components/chatList.js +64 -0
- package/src/ui/components/chatView.js +256 -35
- package/src/ui/components/header.js +25 -1
- package/src/ui/components/inputBox.js +41 -1
- package/src/ui/components/modals/actionModal.js +23 -0
- package/src/ui/components/modals/chatInfoModal.js +9 -0
- package/src/ui/components/modals/confirmModal.js +8 -0
- package/src/ui/components/modals/fileModal.js +26 -1
- package/src/ui/components/modals/filePickerModal.js +9 -0
- package/src/ui/components/modals/helpModal.js +23 -4
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/statusBar.js +54 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +92 -3
- package/src/utils/image.js +8 -8
- package/src/utils/mouse.js +223 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
2
|
import { fg } from "../../theme.js";
|
|
3
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Создаёт модальное окно подтверждения действия (Да / Нет).
|
|
@@ -19,6 +20,7 @@ export function createConfirmModal(screen, theme) {
|
|
|
19
20
|
height: 8,
|
|
20
21
|
hidden: true,
|
|
21
22
|
tags: true,
|
|
23
|
+
mouse: true,
|
|
22
24
|
border: {
|
|
23
25
|
type: "line",
|
|
24
26
|
},
|
|
@@ -113,7 +115,9 @@ export function createConfirmModal(screen, theme) {
|
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
yesBtn.on("press", confirm);
|
|
118
|
+
yesBtn.on("click", confirm);
|
|
116
119
|
noBtn.on("press", hide);
|
|
120
|
+
noBtn.on("click", hide);
|
|
117
121
|
|
|
118
122
|
// Клавиши вешаем на обе кнопки — активна всегда одна из них.
|
|
119
123
|
for (const btn of [yesBtn, noBtn]) {
|
|
@@ -125,12 +129,16 @@ export function createConfirmModal(screen, theme) {
|
|
|
125
129
|
});
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
// Закрытие при клике мышью мимо модального окна
|
|
133
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
134
|
+
|
|
128
135
|
return {
|
|
129
136
|
modal,
|
|
130
137
|
ask: (text, onConfirm) => {
|
|
131
138
|
currentCallback = onConfirm;
|
|
132
139
|
previousFocus = screen.focused;
|
|
133
140
|
msgBox.setContent(`{bold}${text}{/bold}`);
|
|
141
|
+
armOutsideClose();
|
|
134
142
|
modal.show();
|
|
135
143
|
modal.setFront();
|
|
136
144
|
noBtn.focus();
|
|
@@ -4,6 +4,7 @@ import { inspectLocalFile } from "../../../utils/storage.js";
|
|
|
4
4
|
import { formatFileSize } from "../../../utils/time.js";
|
|
5
5
|
import { createFilePickerModal } from "./filePickerModal.js";
|
|
6
6
|
import { fg } from "../../theme.js";
|
|
7
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
7
8
|
|
|
8
9
|
/** Разделитель нескольких путей в поле ввода. */
|
|
9
10
|
const PATH_SEPARATOR = "|";
|
|
@@ -27,6 +28,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
27
28
|
height: 14,
|
|
28
29
|
hidden: true,
|
|
29
30
|
tags: true,
|
|
31
|
+
mouse: true,
|
|
30
32
|
border: {
|
|
31
33
|
type: "line",
|
|
32
34
|
},
|
|
@@ -51,13 +53,15 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
51
53
|
style: { bg: theme.modal.bg, fg: theme.modal.fg },
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
blessed.box({
|
|
56
|
+
const browseBar = blessed.box({
|
|
55
57
|
parent: modal,
|
|
56
58
|
top: 2,
|
|
57
59
|
left: 2,
|
|
58
60
|
right: 2,
|
|
59
61
|
height: 1,
|
|
60
62
|
tags: true,
|
|
63
|
+
mouse: true,
|
|
64
|
+
clickable: true,
|
|
61
65
|
content: `Путь к файлу ${fg(theme.modal.hintFg, `(несколько — через ${PATH_SEPARATOR})`)}{|}${fg(theme.accent, "[Ctrl+F] Обзор")} `,
|
|
62
66
|
style: { bg: theme.modal.bg, fg: theme.modal.fg },
|
|
63
67
|
});
|
|
@@ -69,6 +73,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
69
73
|
right: 2,
|
|
70
74
|
height: 1,
|
|
71
75
|
inputOnFocus: true,
|
|
76
|
+
mouse: true,
|
|
72
77
|
style: {
|
|
73
78
|
bg: theme.modal.inputBg,
|
|
74
79
|
fg: theme.modal.inputFg,
|
|
@@ -93,6 +98,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
93
98
|
right: 2,
|
|
94
99
|
height: 1,
|
|
95
100
|
inputOnFocus: true,
|
|
101
|
+
mouse: true,
|
|
96
102
|
style: {
|
|
97
103
|
bg: theme.modal.inputBg,
|
|
98
104
|
fg: theme.modal.inputFg,
|
|
@@ -247,6 +253,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
247
253
|
asDocumentCheck.uncheck();
|
|
248
254
|
statusLine.setContent("");
|
|
249
255
|
previousFocus = screen.focused;
|
|
256
|
+
armOutsideClose();
|
|
250
257
|
modal.show();
|
|
251
258
|
modal.setFront();
|
|
252
259
|
pathInput.focus();
|
|
@@ -338,10 +345,28 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
338
345
|
});
|
|
339
346
|
captionInput.on("submit", submit);
|
|
340
347
|
sendBtn.on("press", submit);
|
|
348
|
+
sendBtn.on("click", submit);
|
|
341
349
|
cancelBtn.on("press", hide);
|
|
350
|
+
cancelBtn.on("click", hide);
|
|
342
351
|
asDocumentCheck.on("check", () => validate({ quiet: true }));
|
|
343
352
|
asDocumentCheck.on("uncheck", () => validate({ quiet: true }));
|
|
344
353
|
|
|
354
|
+
browseBar.on("click", openPicker);
|
|
355
|
+
pathInput.on("click", () => {
|
|
356
|
+
pathInput.focus();
|
|
357
|
+
screen.render();
|
|
358
|
+
});
|
|
359
|
+
captionInput.on("click", () => {
|
|
360
|
+
captionInput.focus();
|
|
361
|
+
screen.render();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Закрытие при клике мышью мимо модального окна.
|
|
365
|
+
// Пока открыт файловый пикер, клики мимо окна отправки его не закрывают.
|
|
366
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, () => {
|
|
367
|
+
if (!picker.modal.visible) hide();
|
|
368
|
+
});
|
|
369
|
+
|
|
345
370
|
return {
|
|
346
371
|
modal,
|
|
347
372
|
pathInput,
|
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { formatFileSize } from "../../../utils/time.js";
|
|
6
6
|
import { fg } from "../../theme.js";
|
|
7
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Модальное окно выбора локального файла (обёртка над blessed.filemanager).
|
|
@@ -23,6 +24,7 @@ export function createFilePickerModal(screen, theme) {
|
|
|
23
24
|
height: "60%",
|
|
24
25
|
hidden: true,
|
|
25
26
|
tags: true,
|
|
27
|
+
mouse: true,
|
|
26
28
|
border: {
|
|
27
29
|
type: "line",
|
|
28
30
|
},
|
|
@@ -78,6 +80,8 @@ export function createFilePickerModal(screen, theme) {
|
|
|
78
80
|
right: 1,
|
|
79
81
|
height: 2,
|
|
80
82
|
tags: true,
|
|
83
|
+
mouse: true,
|
|
84
|
+
clickable: true,
|
|
81
85
|
style: { bg: theme.modal.bg, fg: theme.modal.fg },
|
|
82
86
|
});
|
|
83
87
|
|
|
@@ -187,6 +191,10 @@ export function createFilePickerModal(screen, theme) {
|
|
|
187
191
|
// filemanager сам отдаёт "cancel" по Escape (list.js), но у него нет hide()
|
|
188
192
|
manager.key(["escape", "q"], hide);
|
|
189
193
|
manager.on("cancel", hide);
|
|
194
|
+
footer.on("click", hide);
|
|
195
|
+
|
|
196
|
+
// Закрытие при клике мышью мимо модального окна
|
|
197
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
190
198
|
|
|
191
199
|
return {
|
|
192
200
|
modal,
|
|
@@ -200,6 +208,7 @@ export function createFilePickerModal(screen, theme) {
|
|
|
200
208
|
onPickCallback = onPick;
|
|
201
209
|
onCloseCallback = onClose;
|
|
202
210
|
const cwd = startDir && fs.existsSync(startDir) ? startDir : os.homedir();
|
|
211
|
+
armOutsideClose();
|
|
203
212
|
modal.show();
|
|
204
213
|
modal.setFront();
|
|
205
214
|
manager.refresh(cwd, () => {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Создаёт модальное окно справки по всем горячим клавишам и возможностям.
|
|
@@ -15,6 +16,8 @@ export function createHelpModal(screen, theme) {
|
|
|
15
16
|
height: "80%",
|
|
16
17
|
hidden: true,
|
|
17
18
|
tags: true,
|
|
19
|
+
mouse: true,
|
|
20
|
+
scrollable: true,
|
|
18
21
|
border: {
|
|
19
22
|
type: "line",
|
|
20
23
|
},
|
|
@@ -41,10 +44,21 @@ export function createHelpModal(screen, theme) {
|
|
|
41
44
|
|
|
42
45
|
{bold}Навигация и фокус:{/bold}
|
|
43
46
|
${K_CYAN}[Tab]${K_END} / ${K_CYAN}[Shift+Tab]${K_END} Фокус по кругу: Список чатов → Лента сообщений → Ввод
|
|
44
|
-
${K_CYAN}[↑] / [↓]${K_END}
|
|
45
|
-
${K_CYAN}[Enter]${K_END} Открыть
|
|
47
|
+
${K_CYAN}[↑] / [↓]${K_END} Список чатов: перемещение · Лента: выделение сообщения
|
|
48
|
+
${K_CYAN}[Enter]${K_END} Открыть чат / Меню действий над выделенным сообщением
|
|
46
49
|
${K_CYAN}[PageUp] / [Ctrl+U]${K_END} Прокрутка сообщений вверх / Подгрузка старой истории
|
|
47
50
|
${K_CYAN}[PageDown] / [Ctrl+D]${K_END}Прокрутка сообщений вниз
|
|
51
|
+
${K_CYAN}[Home] / [End]${K_END} Начало ленты (подгрузка истории) / Последнее сообщение
|
|
52
|
+
|
|
53
|
+
{bold}Управление мышью:{/bold}
|
|
54
|
+
${K_CYAN}Клик по диалогу${K_END} Мгновенно открыть чат
|
|
55
|
+
${K_CYAN}Колесо мыши${K_END} Прокрутка списка чатов и сообщений (вверх — подгрузка истории)
|
|
56
|
+
${K_CYAN}Левый клик по сообщению${K_END} Выделить сообщение (помечается полосой ▌ слева)
|
|
57
|
+
${K_CYAN}Правый клик по сообщению${K_END} Меню действий над сообщением
|
|
58
|
+
${K_CYAN}Клик по картинке${K_END} Открыть изображение на весь экран (${K_CYAN}[Esc]${K_END} — закрыть)
|
|
59
|
+
${K_CYAN}Клик по вкладкам/кнопкам${K_END} Переключение фильтров и вызов действий
|
|
60
|
+
${K_GRAY}macOS Terminal.app перехватывает правый клик — там пользуйтесь [Enter] или [Ctrl+A]${K_END}
|
|
61
|
+
${K_CYAN}[F12]${K_END} Отдать мышь терминалу, чтобы выделить и скопировать текст
|
|
48
62
|
|
|
49
63
|
{bold}Вкладки фильтрации диалогов (нажмите цифру в списке чатов):{/bold}
|
|
50
64
|
${K_YELLOW}[1]${K_END} Все чаты ${K_YELLOW}[2]${K_END} Личные (ЛС) ${K_YELLOW}[3]${K_END} Группы
|
|
@@ -54,8 +68,8 @@ export function createHelpModal(screen, theme) {
|
|
|
54
68
|
{bold}Работа с сообщениями:{/bold}
|
|
55
69
|
${K_GREEN}[Enter]${K_END} Отправить набранный текст
|
|
56
70
|
${K_GREEN}[Ctrl+J]${K_END} Перенос строки без отправки
|
|
57
|
-
${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на последнее
|
|
58
|
-
${K_GREEN}[Ctrl+E]${K_END} Редактировать
|
|
71
|
+
${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на выделенное, иначе — на последнее
|
|
72
|
+
${K_GREEN}[Ctrl+E]${K_END} Редактировать выделенное своё, иначе — последнее своё
|
|
59
73
|
${K_GREEN}[Ctrl+A]${K_END} Контекстное меню действий (Реакции, Удаление, Скачивание)
|
|
60
74
|
${K_GREEN}[Ctrl+O]${K_END} Отправить файл / картинку / документ
|
|
61
75
|
${K_GREEN} [Ctrl+F]${K_END} — в окне отправки: обзор файлов
|
|
@@ -113,6 +127,7 @@ export function createHelpModal(screen, theme) {
|
|
|
113
127
|
|
|
114
128
|
function show() {
|
|
115
129
|
previousFocus = screen.focused;
|
|
130
|
+
armOutsideClose();
|
|
116
131
|
modal.show();
|
|
117
132
|
modal.setFront();
|
|
118
133
|
closeBtn.focus();
|
|
@@ -120,9 +135,13 @@ export function createHelpModal(screen, theme) {
|
|
|
120
135
|
}
|
|
121
136
|
|
|
122
137
|
closeBtn.on("press", hide);
|
|
138
|
+
closeBtn.on("click", hide);
|
|
123
139
|
// Клавиши вешаем на кнопку: blessed отдаёт события только сфокусированному элементу.
|
|
124
140
|
closeBtn.key(["escape", "q", "f1"], hide);
|
|
125
141
|
|
|
142
|
+
// Закрытие при клике мышью мимо модального окна
|
|
143
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
144
|
+
|
|
126
145
|
return {
|
|
127
146
|
modal,
|
|
128
147
|
show,
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import blessed from "neo-blessed";
|
|
2
|
+
import { fg } from "../../theme.js";
|
|
3
|
+
import { renderImageBuffer } from "../../../utils/image.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Создаёт полноэкранный просмотрщик изображений.
|
|
7
|
+
*
|
|
8
|
+
* Сначала показывается встроенная в сообщение миниатюра (мгновенно, без сети),
|
|
9
|
+
* затем она заменяется полноразмерной версией, как только та скачается.
|
|
10
|
+
*
|
|
11
|
+
* @param {blessed.Widgets.Screen} screen
|
|
12
|
+
* @param {object} theme
|
|
13
|
+
* @param {object} callbacks
|
|
14
|
+
* @param {(msg: object) => Promise<{ buffer: Buffer, mimeType: string }>} [callbacks.onLoadFullImage]
|
|
15
|
+
* @param {(msg: object, size: { maxWidth: number, maxHeight: number }) => string} [callbacks.onRenderPlaceholder]
|
|
16
|
+
* @returns {{ modal: object, show: (msg: object) => void, hide: () => void, isVisible: () => boolean }}
|
|
17
|
+
*/
|
|
18
|
+
export function createImageViewerModal(screen, theme, { onLoadFullImage, onRenderPlaceholder } = {}) {
|
|
19
|
+
const modal = blessed.box({
|
|
20
|
+
parent: screen,
|
|
21
|
+
top: 0,
|
|
22
|
+
left: 0,
|
|
23
|
+
width: "100%",
|
|
24
|
+
height: "100%",
|
|
25
|
+
hidden: true,
|
|
26
|
+
mouse: true,
|
|
27
|
+
style: {
|
|
28
|
+
bg: theme.bg,
|
|
29
|
+
fg: theme.fg,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const canvas = blessed.box({
|
|
34
|
+
parent: modal,
|
|
35
|
+
top: 0,
|
|
36
|
+
left: 0,
|
|
37
|
+
right: 0,
|
|
38
|
+
bottom: 1,
|
|
39
|
+
tags: true,
|
|
40
|
+
align: "center",
|
|
41
|
+
valign: "middle",
|
|
42
|
+
style: {
|
|
43
|
+
bg: theme.bg,
|
|
44
|
+
fg: theme.fg,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const footer = blessed.box({
|
|
49
|
+
parent: modal,
|
|
50
|
+
bottom: 0,
|
|
51
|
+
left: 0,
|
|
52
|
+
right: 0,
|
|
53
|
+
height: 1,
|
|
54
|
+
tags: true,
|
|
55
|
+
style: {
|
|
56
|
+
bg: theme.status.bg,
|
|
57
|
+
fg: theme.status.fg,
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
let currentMsg = null;
|
|
62
|
+
let fullImage = null;
|
|
63
|
+
let statusText = "";
|
|
64
|
+
let previousFocus = null;
|
|
65
|
+
/** Счётчик открытий: результат опоздавшей загрузки не должен затирать новую картинку. */
|
|
66
|
+
let token = 0;
|
|
67
|
+
|
|
68
|
+
/** Размер холста в ячейках терминала. */
|
|
69
|
+
function viewportSize() {
|
|
70
|
+
return {
|
|
71
|
+
maxWidth: Math.max(8, (screen.width || 80) - 2),
|
|
72
|
+
maxHeight: Math.max(4, (screen.height || 24) - 2),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function renderFooter() {
|
|
77
|
+
const id = currentMsg ? `#${currentMsg.id}` : "";
|
|
78
|
+
const status = statusText ? `${fg(theme.warning, statusText)} ${fg(theme.dim, "│")} ` : "";
|
|
79
|
+
footer.setContent(
|
|
80
|
+
` ${fg(theme.accent, id)} ${fg(theme.dim, "│")} ${status}${fg(theme.muted, "[Esc] закрыть · клик — закрыть")}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Перерисовывает картинку под текущий размер терминала. */
|
|
85
|
+
function repaint() {
|
|
86
|
+
if (!currentMsg) return;
|
|
87
|
+
const size = viewportSize();
|
|
88
|
+
|
|
89
|
+
let content = "";
|
|
90
|
+
if (fullImage?.buffer) {
|
|
91
|
+
content = renderImageBuffer(fullImage.buffer, {
|
|
92
|
+
mimeType: fullImage.mimeType,
|
|
93
|
+
maxWidth: size.maxWidth,
|
|
94
|
+
maxHeight: size.maxHeight,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (!content) {
|
|
98
|
+
content = onRenderPlaceholder?.(currentMsg, size) || "";
|
|
99
|
+
}
|
|
100
|
+
if (!content) {
|
|
101
|
+
content = fg(theme.muted, "Изображение недоступно");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
canvas.setContent(content);
|
|
105
|
+
renderFooter();
|
|
106
|
+
screen.render();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function hide() {
|
|
110
|
+
// Инвалидируем незавершённую загрузку: её результат уже не нужен
|
|
111
|
+
token++;
|
|
112
|
+
modal.hide();
|
|
113
|
+
currentMsg = null;
|
|
114
|
+
fullImage = null;
|
|
115
|
+
statusText = "";
|
|
116
|
+
canvas.setContent("");
|
|
117
|
+
if (previousFocus) {
|
|
118
|
+
previousFocus.focus();
|
|
119
|
+
previousFocus = null;
|
|
120
|
+
}
|
|
121
|
+
screen.render();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Открывает изображение сообщения на весь экран.
|
|
126
|
+
* @param {object} msg нормализованное сообщение
|
|
127
|
+
*/
|
|
128
|
+
function show(msg) {
|
|
129
|
+
if (!msg) return;
|
|
130
|
+
|
|
131
|
+
const myToken = ++token;
|
|
132
|
+
currentMsg = msg;
|
|
133
|
+
fullImage = null;
|
|
134
|
+
statusText = "Загрузка полного изображения...";
|
|
135
|
+
previousFocus = screen.focused;
|
|
136
|
+
|
|
137
|
+
repaint();
|
|
138
|
+
modal.show();
|
|
139
|
+
modal.setFront();
|
|
140
|
+
modal.focus();
|
|
141
|
+
screen.render();
|
|
142
|
+
|
|
143
|
+
Promise.resolve()
|
|
144
|
+
.then(() => onLoadFullImage?.(msg))
|
|
145
|
+
.then((result) => {
|
|
146
|
+
if (myToken !== token || !result?.buffer) return;
|
|
147
|
+
fullImage = result;
|
|
148
|
+
statusText = "";
|
|
149
|
+
repaint();
|
|
150
|
+
})
|
|
151
|
+
.catch((err) => {
|
|
152
|
+
if (myToken !== token) return;
|
|
153
|
+
statusText = `Не удалось загрузить: ${err.message}`;
|
|
154
|
+
renderFooter();
|
|
155
|
+
screen.render();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
modal.key(["escape", "q", "enter", "return", "space"], hide);
|
|
160
|
+
modal.on("click", hide);
|
|
161
|
+
|
|
162
|
+
screen.on("resize", () => {
|
|
163
|
+
if (modal.visible) repaint();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
modal,
|
|
168
|
+
show,
|
|
169
|
+
hide,
|
|
170
|
+
isVisible: () => modal.visible,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
2
|
import { fg } from "../theme.js";
|
|
3
3
|
|
|
4
|
+
import { getStatusBarActionAt, isRightClick } from "../../utils/mouse.js";
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Создаёт нижнюю строку состояния (Status Bar) с подсказками и временными тостами.
|
|
6
8
|
* @param {blessed.Widgets.Screen} screen
|
|
7
9
|
* @param {object} theme
|
|
10
|
+
* @param {object} [callbacks]
|
|
11
|
+
* @param {() => void} [callbacks.onFocusNext]
|
|
12
|
+
* @param {() => void} [callbacks.onSelectOrSubmit]
|
|
13
|
+
* @param {() => void} [callbacks.onFilterTabs]
|
|
14
|
+
* @param {() => void} [callbacks.onSearch]
|
|
15
|
+
* @param {() => void} [callbacks.onHelp]
|
|
16
|
+
* @param {() => void} [callbacks.onAction]
|
|
17
|
+
* @param {() => void} [callbacks.onChatInfo]
|
|
18
|
+
* @param {() => void} [callbacks.onQuit]
|
|
8
19
|
* @returns {blessed.Widgets.BoxElement & { showMessage: (text: string, type?: string, duration?: number) => void }}
|
|
9
20
|
*/
|
|
10
|
-
export function createStatusBar(screen, theme
|
|
21
|
+
export function createStatusBar(screen, theme, {
|
|
22
|
+
onFocusNext,
|
|
23
|
+
onSelectOrSubmit,
|
|
24
|
+
onFilterTabs,
|
|
25
|
+
onSearch,
|
|
26
|
+
onHelp,
|
|
27
|
+
onAction,
|
|
28
|
+
onChatInfo,
|
|
29
|
+
onQuit,
|
|
30
|
+
} = {}) {
|
|
11
31
|
const statusBar = blessed.box({
|
|
12
32
|
parent: screen,
|
|
13
33
|
bottom: 0,
|
|
@@ -15,12 +35,45 @@ export function createStatusBar(screen, theme) {
|
|
|
15
35
|
width: "100%",
|
|
16
36
|
height: 1,
|
|
17
37
|
tags: true,
|
|
38
|
+
mouse: true,
|
|
18
39
|
style: {
|
|
19
40
|
bg: theme.status.bg,
|
|
20
41
|
fg: theme.status.fg,
|
|
21
42
|
},
|
|
22
43
|
});
|
|
23
44
|
|
|
45
|
+
statusBar.on("click", (data) => {
|
|
46
|
+
if (isRightClick(data)) return;
|
|
47
|
+
const relX = data.x - (statusBar.aleft || 0);
|
|
48
|
+
const action = getStatusBarActionAt(relX, statusBar.width || 120);
|
|
49
|
+
switch (action) {
|
|
50
|
+
case "focus":
|
|
51
|
+
onFocusNext?.();
|
|
52
|
+
break;
|
|
53
|
+
case "select":
|
|
54
|
+
onSelectOrSubmit?.();
|
|
55
|
+
break;
|
|
56
|
+
case "tabs":
|
|
57
|
+
onFilterTabs?.();
|
|
58
|
+
break;
|
|
59
|
+
case "search":
|
|
60
|
+
onSearch?.();
|
|
61
|
+
break;
|
|
62
|
+
case "help":
|
|
63
|
+
onHelp?.();
|
|
64
|
+
break;
|
|
65
|
+
case "actions":
|
|
66
|
+
onAction?.();
|
|
67
|
+
break;
|
|
68
|
+
case "info":
|
|
69
|
+
onChatInfo?.();
|
|
70
|
+
break;
|
|
71
|
+
case "quit":
|
|
72
|
+
onQuit?.();
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
24
77
|
let currentTimeout = null;
|
|
25
78
|
|
|
26
79
|
const key = (label) => `{bold}${fg(theme.accent, label)}{/bold}`;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Общее мышиное поведение модальных окон.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { isInsideBox } from "../utils/mouse.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Подключает закрытие модального окна по клику мимо него.
|
|
9
|
+
*
|
|
10
|
+
* neo-blessed рассылает клик сначала элементу под курсором, а сразу за ним, в том же
|
|
11
|
+
* такте, — экрану (см. Screen.prototype._listenMouse). Поэтому клик, которым окно
|
|
12
|
+
* открыли (кнопка строки состояния, шапка, сообщение в ленте), доходил бы до этого
|
|
13
|
+
* обработчика и закрывал только что открытое окно. Возвращаемая функция «взводит»
|
|
14
|
+
* закрытие лишь на следующем такте — её вызывает show() окна.
|
|
15
|
+
*
|
|
16
|
+
* @param {import("neo-blessed").Widgets.Screen} screen
|
|
17
|
+
* @param {import("neo-blessed").Widgets.BoxElement} modal
|
|
18
|
+
* @param {() => void} hide
|
|
19
|
+
* @returns {() => void} arm — взводит закрытие по внешнему клику
|
|
20
|
+
*/
|
|
21
|
+
export function bindOutsideClickClose(screen, modal, hide) {
|
|
22
|
+
let armed = false;
|
|
23
|
+
|
|
24
|
+
screen.on("click", (data) => {
|
|
25
|
+
if (!armed || !modal.visible) return;
|
|
26
|
+
const inside = isInsideBox(data.x, data.y, {
|
|
27
|
+
left: modal.aleft,
|
|
28
|
+
top: modal.atop,
|
|
29
|
+
width: modal.width,
|
|
30
|
+
height: modal.height,
|
|
31
|
+
});
|
|
32
|
+
if (!inside) {
|
|
33
|
+
hide();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return () => {
|
|
38
|
+
armed = false;
|
|
39
|
+
setImmediate(() => {
|
|
40
|
+
armed = true;
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/ui/screen.js
CHANGED
|
@@ -15,10 +15,58 @@ export const GLOBAL_KEYS = [
|
|
|
15
15
|
"C-r",
|
|
16
16
|
"C-e",
|
|
17
17
|
"C-p",
|
|
18
|
+
// C-a — меню действий над выделенным сообщением, f12 — тумблер захвата мыши
|
|
19
|
+
"C-a",
|
|
20
|
+
"f12",
|
|
18
21
|
// escape — чтобы можно было прервать отправку файла из любого места
|
|
19
22
|
"escape",
|
|
20
23
|
];
|
|
21
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Включение мыши: 1000 — кнопки, 1002 — клики/перетаскивание/колесо,
|
|
27
|
+
* 1006 — SGR-кодирование координат, 1015 — urxvt как запасной вариант.
|
|
28
|
+
* Режимы 1003 (все движения) и 1005 (UTF-8) явно гасим: они ломают разбор в blessed.
|
|
29
|
+
*/
|
|
30
|
+
const ENABLE_MOUSE_SEQ = "\x1b[?1003l\x1b[?1005l\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h";
|
|
31
|
+
const DISABLE_MOUSE_SEQ = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Пишет escape-последовательность напрямую в терминал.
|
|
35
|
+
* @param {import("neo-blessed").Widgets.Screen} screen
|
|
36
|
+
* @param {string} seq
|
|
37
|
+
*/
|
|
38
|
+
function writeSeq(screen, seq) {
|
|
39
|
+
try {
|
|
40
|
+
if (screen.program?.output && typeof screen.program.output.write === "function") {
|
|
41
|
+
screen.program.output.write(seq);
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Игнорируем в headless/тестах
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Включает или выключает захват мыши терминалом приложения.
|
|
50
|
+
* Пока захват включён, терминал не отдаёт пользователю выделение текста мышью,
|
|
51
|
+
* поэтому нужен способ временно его отпустить (F12).
|
|
52
|
+
* @param {import("neo-blessed").Widgets.Screen} screen
|
|
53
|
+
* @param {boolean} enabled
|
|
54
|
+
*/
|
|
55
|
+
export function setMouseCapture(screen, enabled) {
|
|
56
|
+
screen.mouseCaptured = enabled;
|
|
57
|
+
if (enabled) {
|
|
58
|
+
writeSeq(screen, ENABLE_MOUSE_SEQ);
|
|
59
|
+
screen.program.enableMouse();
|
|
60
|
+
} else {
|
|
61
|
+
try {
|
|
62
|
+
screen.program.disableMouse();
|
|
63
|
+
} catch {
|
|
64
|
+
// Игнорируем в headless/тестах
|
|
65
|
+
}
|
|
66
|
+
writeSeq(screen, DISABLE_MOUSE_SEQ);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
22
70
|
/**
|
|
23
71
|
* Создаёт и настраивает главный экран терминального интерфейса.
|
|
24
72
|
* @param {object} [options]
|
|
@@ -27,11 +75,15 @@ export const GLOBAL_KEYS = [
|
|
|
27
75
|
* @returns {blessed.Widgets.Screen}
|
|
28
76
|
*/
|
|
29
77
|
export function createScreen({ theme, onExit } = {}) {
|
|
78
|
+
// Форсируем поддержку SGR и CellMotion режимов в neo-blessed для современных терминалов
|
|
79
|
+
process.env.BLESSED_FORCE_MODES = "SGRMOUSE=1,CELLMOTION=1,VT200MOUSE=1,UTFMOUSE=0,ALLMOTION=0,URXVTMOUSE=1";
|
|
80
|
+
|
|
30
81
|
const screen = blessed.screen({
|
|
31
82
|
smartCSR: true,
|
|
32
83
|
title: "TuiGram - Telegram Terminal Client",
|
|
33
84
|
fullUnicode: true,
|
|
34
85
|
dockBorders: true,
|
|
86
|
+
sendFocus: true,
|
|
35
87
|
cursor: {
|
|
36
88
|
synthetic: true,
|
|
37
89
|
blink: true,
|
|
@@ -40,21 +92,58 @@ export function createScreen({ theme, onExit } = {}) {
|
|
|
40
92
|
style: {
|
|
41
93
|
bg: theme?.bg ?? "black",
|
|
42
94
|
fg: theme?.fg ?? "white",
|
|
43
|
-
}
|
|
95
|
+
},
|
|
44
96
|
});
|
|
45
97
|
|
|
46
98
|
screen.ignoreLocked = [...GLOBAL_KEYS];
|
|
47
99
|
|
|
100
|
+
// Настраиваем режимы мыши в program:
|
|
101
|
+
// SGR (1006) — стандарт для macOS Terminal, iTerm2, Alacritty, Kitty, Windows Terminal
|
|
102
|
+
// CellMotion (1002) — клики, зажатия и скролл
|
|
103
|
+
// VT200 (1000) — базовая поддержка кнопок
|
|
104
|
+
// urxvt (1015) — fallback
|
|
105
|
+
screen.program.setMouse({
|
|
106
|
+
vt200Mouse: true,
|
|
107
|
+
cellMotion: true,
|
|
108
|
+
allMotion: false,
|
|
109
|
+
sgrMouse: true,
|
|
110
|
+
urxvtMouse: true,
|
|
111
|
+
utfMouse: false,
|
|
112
|
+
}, true);
|
|
113
|
+
|
|
114
|
+
screen.mouseCaptured = true;
|
|
115
|
+
writeSeq(screen, ENABLE_MOUSE_SEQ);
|
|
116
|
+
screen.enableMouse();
|
|
117
|
+
|
|
118
|
+
// Режимы мыши переотправляются после каждой перерисовки и при ресайзе.
|
|
119
|
+
// Это выглядит избыточным, но без этого часть терминалов перестаёт слать
|
|
120
|
+
// события мыши приложению: попытка убрать переотправку ломала клики целиком.
|
|
121
|
+
// Не удалять без проверки в живом терминале — headless-тесты этого не ловят.
|
|
122
|
+
function restoreMouse() {
|
|
123
|
+
if (screen.mouseCaptured) writeSeq(screen, ENABLE_MOUSE_SEQ);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
screen.on("render", restoreMouse);
|
|
127
|
+
screen.on("resize", restoreMouse);
|
|
128
|
+
|
|
129
|
+
// Пробрасываем событие click на уровне экрана при mouseup
|
|
130
|
+
screen.on("mouseup", (data) => {
|
|
131
|
+
screen.emit("click", data);
|
|
132
|
+
});
|
|
133
|
+
|
|
48
134
|
// Обработка закрытия терминала или аварийного прерывания
|
|
49
|
-
|
|
135
|
+
function cleanExit() {
|
|
50
136
|
try {
|
|
137
|
+
setMouseCapture(screen, false);
|
|
51
138
|
onExit?.();
|
|
52
139
|
} catch {
|
|
53
140
|
// Выходим в любом случае
|
|
54
141
|
}
|
|
55
142
|
screen.destroy();
|
|
56
143
|
process.exit(0);
|
|
57
|
-
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
screen.key(["C-c"], cleanExit);
|
|
58
147
|
|
|
59
148
|
return screen;
|
|
60
149
|
}
|