@emaxe/tuigram 1.3.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 +43 -1
- package/CHANGELOG.ru.md +47 -1
- package/README.md +50 -14
- package/README.ru.md +51 -14
- package/package.json +1 -1
- package/src/telegram/messages.js +98 -19
- package/src/ui/app.js +95 -40
- package/src/ui/components/chatList.js +4 -2
- package/src/ui/components/chatView.js +225 -61
- package/src/ui/components/header.js +2 -1
- package/src/ui/components/inputBox.js +2 -1
- package/src/ui/components/modals/actionModal.js +6 -14
- package/src/ui/components/modals/chatInfoModal.js +3 -13
- package/src/ui/components/modals/confirmModal.js +3 -13
- package/src/ui/components/modals/fileModal.js +6 -13
- package/src/ui/components/modals/filePickerModal.js +3 -13
- package/src/ui/components/modals/helpModal.js +13 -18
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/statusBar.js +2 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +60 -20
- package/src/utils/image.js +8 -8
- package/src/utils/mouse.js +61 -74
|
@@ -2,7 +2,7 @@ import blessed from "neo-blessed";
|
|
|
2
2
|
import { escapeBlessed } from "../../telegram/formatter.js";
|
|
3
3
|
import { fg } from "../theme.js";
|
|
4
4
|
|
|
5
|
-
import { getHeaderActionAt } from "../../utils/mouse.js";
|
|
5
|
+
import { getHeaderActionAt, isRightClick } from "../../utils/mouse.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Верхняя шапка приложения с информацией о пользователе, активном чате и статусе соединения.
|
|
@@ -39,6 +39,7 @@ export function createHeader(screen, theme, { onHelp, onChatInfo, onStatusClick
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
headerBox.on("click", (data) => {
|
|
42
|
+
if (isRightClick(data)) return;
|
|
42
43
|
const relX = data.x - (headerBox.aleft || 0);
|
|
43
44
|
const relY = data.y - (headerBox.atop || 0);
|
|
44
45
|
const action = getHeaderActionAt(relX, relY, { hasActiveChat: Boolean(currentActiveChat) });
|
|
@@ -2,7 +2,7 @@ import blessed from "neo-blessed";
|
|
|
2
2
|
import { escapeBlessed } from "../../telegram/formatter.js";
|
|
3
3
|
import { fg, badge } from "../theme.js";
|
|
4
4
|
|
|
5
|
-
import { getInputContextActionAt } from "../../utils/mouse.js";
|
|
5
|
+
import { getInputContextActionAt, isRightClick } from "../../utils/mouse.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Создаёт компонент поля ввода сообщения (нижняя панель).
|
|
@@ -124,6 +124,7 @@ export function createInputBox(screen, theme, {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
contextBar.on("click", (data) => {
|
|
127
|
+
if (isRightClick(data)) return;
|
|
127
128
|
const relX = data.x - (contextBar.aleft || 0);
|
|
128
129
|
const action = getInputContextActionAt(relX, currentMode);
|
|
129
130
|
if (action === "cancel") {
|
|
@@ -2,7 +2,8 @@ import blessed from "neo-blessed";
|
|
|
2
2
|
import { escapeBlessed } from "../../../telegram/formatter.js";
|
|
3
3
|
import { fg } from "../../theme.js";
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { isRightClick } from "../../../utils/mouse.js";
|
|
6
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Создаёт модальное окно контекстных действий над сообщением.
|
|
@@ -72,7 +73,8 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
72
73
|
const baseCreateItem = list.createItem.bind(list);
|
|
73
74
|
list.createItem = (content) => {
|
|
74
75
|
const item = baseCreateItem(content);
|
|
75
|
-
item.on("click", () => {
|
|
76
|
+
item.on("click", (data) => {
|
|
77
|
+
if (isRightClick(data)) return;
|
|
76
78
|
const index = list.getItemIndex(item);
|
|
77
79
|
const action = currentActions[index];
|
|
78
80
|
if (action && currentMsg) {
|
|
@@ -128,6 +130,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
128
130
|
|
|
129
131
|
list.setItems(currentActions.map((a) => a.label));
|
|
130
132
|
previousFocus = screen.focused;
|
|
133
|
+
armOutsideClose();
|
|
131
134
|
modal.show();
|
|
132
135
|
modal.setFront();
|
|
133
136
|
list.focus();
|
|
@@ -146,18 +149,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
|
|
|
146
149
|
list.key(["escape", "q"], hide);
|
|
147
150
|
|
|
148
151
|
// Закрытие при клике мышью мимо модального окна
|
|
149
|
-
screen
|
|
150
|
-
if (!modal.visible) return;
|
|
151
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
152
|
-
left: modal.aleft,
|
|
153
|
-
top: modal.atop,
|
|
154
|
-
width: modal.width,
|
|
155
|
-
height: modal.height,
|
|
156
|
-
});
|
|
157
|
-
if (!inside) {
|
|
158
|
-
hide();
|
|
159
|
-
}
|
|
160
|
-
});
|
|
152
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
161
153
|
|
|
162
154
|
return {
|
|
163
155
|
modal,
|
|
@@ -3,7 +3,7 @@ import { escapeBlessed } from "../../../telegram/formatter.js";
|
|
|
3
3
|
import { idToString } from "../../../telegram/entities.js";
|
|
4
4
|
import { fg } from "../../theme.js";
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Создаёт модальное окно информации о текущем чате.
|
|
@@ -111,6 +111,7 @@ export function createChatInfoModal(screen, theme) {
|
|
|
111
111
|
|
|
112
112
|
infoText.setContent(body);
|
|
113
113
|
previousFocus = screen.focused;
|
|
114
|
+
armOutsideClose();
|
|
114
115
|
modal.show();
|
|
115
116
|
modal.setFront();
|
|
116
117
|
closeBtn.focus();
|
|
@@ -123,18 +124,7 @@ export function createChatInfoModal(screen, theme) {
|
|
|
123
124
|
closeBtn.key(["escape", "q"], hide);
|
|
124
125
|
|
|
125
126
|
// Закрытие при клике мышью мимо модального окна
|
|
126
|
-
screen
|
|
127
|
-
if (!modal.visible) return;
|
|
128
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
129
|
-
left: modal.aleft,
|
|
130
|
-
top: modal.atop,
|
|
131
|
-
width: modal.width,
|
|
132
|
-
height: modal.height,
|
|
133
|
-
});
|
|
134
|
-
if (!inside) {
|
|
135
|
-
hide();
|
|
136
|
-
}
|
|
137
|
-
});
|
|
127
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
138
128
|
|
|
139
129
|
return {
|
|
140
130
|
modal,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
2
|
import { fg } from "../../theme.js";
|
|
3
|
-
import {
|
|
3
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Создаёт модальное окно подтверждения действия (Да / Нет).
|
|
@@ -130,18 +130,7 @@ export function createConfirmModal(screen, theme) {
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
// Закрытие при клике мышью мимо модального окна
|
|
133
|
-
screen
|
|
134
|
-
if (!modal.visible) return;
|
|
135
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
136
|
-
left: modal.aleft,
|
|
137
|
-
top: modal.atop,
|
|
138
|
-
width: modal.width,
|
|
139
|
-
height: modal.height,
|
|
140
|
-
});
|
|
141
|
-
if (!inside) {
|
|
142
|
-
hide();
|
|
143
|
-
}
|
|
144
|
-
});
|
|
133
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
145
134
|
|
|
146
135
|
return {
|
|
147
136
|
modal,
|
|
@@ -149,6 +138,7 @@ export function createConfirmModal(screen, theme) {
|
|
|
149
138
|
currentCallback = onConfirm;
|
|
150
139
|
previousFocus = screen.focused;
|
|
151
140
|
msgBox.setContent(`{bold}${text}{/bold}`);
|
|
141
|
+
armOutsideClose();
|
|
152
142
|
modal.show();
|
|
153
143
|
modal.setFront();
|
|
154
144
|
noBtn.focus();
|
|
@@ -4,7 +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 {
|
|
7
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
8
8
|
|
|
9
9
|
/** Разделитель нескольких путей в поле ввода. */
|
|
10
10
|
const PATH_SEPARATOR = "|";
|
|
@@ -253,6 +253,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
253
253
|
asDocumentCheck.uncheck();
|
|
254
254
|
statusLine.setContent("");
|
|
255
255
|
previousFocus = screen.focused;
|
|
256
|
+
armOutsideClose();
|
|
256
257
|
modal.show();
|
|
257
258
|
modal.setFront();
|
|
258
259
|
pathInput.focus();
|
|
@@ -360,18 +361,10 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
360
361
|
screen.render();
|
|
361
362
|
});
|
|
362
363
|
|
|
363
|
-
// Закрытие при клике мышью мимо модального
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
left: modal.aleft,
|
|
368
|
-
top: modal.atop,
|
|
369
|
-
width: modal.width,
|
|
370
|
-
height: modal.height,
|
|
371
|
-
});
|
|
372
|
-
if (!inside) {
|
|
373
|
-
hide();
|
|
374
|
-
}
|
|
364
|
+
// Закрытие при клике мышью мимо модального окна.
|
|
365
|
+
// Пока открыт файловый пикер, клики мимо окна отправки его не закрывают.
|
|
366
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, () => {
|
|
367
|
+
if (!picker.modal.visible) hide();
|
|
375
368
|
});
|
|
376
369
|
|
|
377
370
|
return {
|
|
@@ -4,7 +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 {
|
|
7
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Модальное окно выбора локального файла (обёртка над blessed.filemanager).
|
|
@@ -194,18 +194,7 @@ export function createFilePickerModal(screen, theme) {
|
|
|
194
194
|
footer.on("click", hide);
|
|
195
195
|
|
|
196
196
|
// Закрытие при клике мышью мимо модального окна
|
|
197
|
-
screen
|
|
198
|
-
if (!modal.visible) return;
|
|
199
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
200
|
-
left: modal.aleft,
|
|
201
|
-
top: modal.atop,
|
|
202
|
-
width: modal.width,
|
|
203
|
-
height: modal.height,
|
|
204
|
-
});
|
|
205
|
-
if (!inside) {
|
|
206
|
-
hide();
|
|
207
|
-
}
|
|
208
|
-
});
|
|
197
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
209
198
|
|
|
210
199
|
return {
|
|
211
200
|
modal,
|
|
@@ -219,6 +208,7 @@ export function createFilePickerModal(screen, theme) {
|
|
|
219
208
|
onPickCallback = onPick;
|
|
220
209
|
onCloseCallback = onClose;
|
|
221
210
|
const cwd = startDir && fs.existsSync(startDir) ? startDir : os.homedir();
|
|
211
|
+
armOutsideClose();
|
|
222
212
|
modal.show();
|
|
223
213
|
modal.setFront();
|
|
224
214
|
manager.refresh(cwd, () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
|
-
import {
|
|
2
|
+
import { bindOutsideClickClose } from "../../modalMouse.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Создаёт модальное окно справки по всем горячим клавишам и возможностям.
|
|
@@ -44,16 +44,21 @@ export function createHelpModal(screen, theme) {
|
|
|
44
44
|
|
|
45
45
|
{bold}Навигация и фокус:{/bold}
|
|
46
46
|
${K_CYAN}[Tab]${K_END} / ${K_CYAN}[Shift+Tab]${K_END} Фокус по кругу: Список чатов → Лента сообщений → Ввод
|
|
47
|
-
${K_CYAN}[↑] / [↓]${K_END}
|
|
48
|
-
${K_CYAN}[Enter]${K_END} Открыть
|
|
47
|
+
${K_CYAN}[↑] / [↓]${K_END} Список чатов: перемещение · Лента: выделение сообщения
|
|
48
|
+
${K_CYAN}[Enter]${K_END} Открыть чат / Меню действий над выделенным сообщением
|
|
49
49
|
${K_CYAN}[PageUp] / [Ctrl+U]${K_END} Прокрутка сообщений вверх / Подгрузка старой истории
|
|
50
50
|
${K_CYAN}[PageDown] / [Ctrl+D]${K_END}Прокрутка сообщений вниз
|
|
51
|
+
${K_CYAN}[Home] / [End]${K_END} Начало ленты (подгрузка истории) / Последнее сообщение
|
|
51
52
|
|
|
52
53
|
{bold}Управление мышью:{/bold}
|
|
53
54
|
${K_CYAN}Клик по диалогу${K_END} Мгновенно открыть чат
|
|
54
55
|
${K_CYAN}Колесо мыши${K_END} Прокрутка списка чатов и сообщений (вверх — подгрузка истории)
|
|
55
|
-
${K_CYAN}
|
|
56
|
+
${K_CYAN}Левый клик по сообщению${K_END} Выделить сообщение (помечается полосой ▌ слева)
|
|
57
|
+
${K_CYAN}Правый клик по сообщению${K_END} Меню действий над сообщением
|
|
58
|
+
${K_CYAN}Клик по картинке${K_END} Открыть изображение на весь экран (${K_CYAN}[Esc]${K_END} — закрыть)
|
|
56
59
|
${K_CYAN}Клик по вкладкам/кнопкам${K_END} Переключение фильтров и вызов действий
|
|
60
|
+
${K_GRAY}macOS Terminal.app перехватывает правый клик — там пользуйтесь [Enter] или [Ctrl+A]${K_END}
|
|
61
|
+
${K_CYAN}[F12]${K_END} Отдать мышь терминалу, чтобы выделить и скопировать текст
|
|
57
62
|
|
|
58
63
|
{bold}Вкладки фильтрации диалогов (нажмите цифру в списке чатов):{/bold}
|
|
59
64
|
${K_YELLOW}[1]${K_END} Все чаты ${K_YELLOW}[2]${K_END} Личные (ЛС) ${K_YELLOW}[3]${K_END} Группы
|
|
@@ -63,8 +68,8 @@ export function createHelpModal(screen, theme) {
|
|
|
63
68
|
{bold}Работа с сообщениями:{/bold}
|
|
64
69
|
${K_GREEN}[Enter]${K_END} Отправить набранный текст
|
|
65
70
|
${K_GREEN}[Ctrl+J]${K_END} Перенос строки без отправки
|
|
66
|
-
${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на последнее
|
|
67
|
-
${K_GREEN}[Ctrl+E]${K_END} Редактировать
|
|
71
|
+
${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на выделенное, иначе — на последнее
|
|
72
|
+
${K_GREEN}[Ctrl+E]${K_END} Редактировать выделенное своё, иначе — последнее своё
|
|
68
73
|
${K_GREEN}[Ctrl+A]${K_END} Контекстное меню действий (Реакции, Удаление, Скачивание)
|
|
69
74
|
${K_GREEN}[Ctrl+O]${K_END} Отправить файл / картинку / документ
|
|
70
75
|
${K_GREEN} [Ctrl+F]${K_END} — в окне отправки: обзор файлов
|
|
@@ -122,6 +127,7 @@ export function createHelpModal(screen, theme) {
|
|
|
122
127
|
|
|
123
128
|
function show() {
|
|
124
129
|
previousFocus = screen.focused;
|
|
130
|
+
armOutsideClose();
|
|
125
131
|
modal.show();
|
|
126
132
|
modal.setFront();
|
|
127
133
|
closeBtn.focus();
|
|
@@ -134,18 +140,7 @@ export function createHelpModal(screen, theme) {
|
|
|
134
140
|
closeBtn.key(["escape", "q", "f1"], hide);
|
|
135
141
|
|
|
136
142
|
// Закрытие при клике мышью мимо модального окна
|
|
137
|
-
screen
|
|
138
|
-
if (!modal.visible) return;
|
|
139
|
-
const inside = isInsideBox(data.x, data.y, {
|
|
140
|
-
left: modal.aleft,
|
|
141
|
-
top: modal.atop,
|
|
142
|
-
width: modal.width,
|
|
143
|
-
height: modal.height,
|
|
144
|
-
});
|
|
145
|
-
if (!inside) {
|
|
146
|
-
hide();
|
|
147
|
-
}
|
|
148
|
-
});
|
|
143
|
+
const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
|
|
149
144
|
|
|
150
145
|
return {
|
|
151
146
|
modal,
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
2
|
import { fg } from "../theme.js";
|
|
3
3
|
|
|
4
|
-
import { getStatusBarActionAt } from "../../utils/mouse.js";
|
|
4
|
+
import { getStatusBarActionAt, isRightClick } from "../../utils/mouse.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Создаёт нижнюю строку состояния (Status Bar) с подсказками и временными тостами.
|
|
@@ -43,6 +43,7 @@ export function createStatusBar(screen, theme, {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
statusBar.on("click", (data) => {
|
|
46
|
+
if (isRightClick(data)) return;
|
|
46
47
|
const relX = data.x - (statusBar.aleft || 0);
|
|
47
48
|
const action = getStatusBarActionAt(relX, statusBar.width || 120);
|
|
48
49
|
switch (action) {
|
|
@@ -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]
|
|
@@ -63,25 +111,20 @@ export function createScreen({ theme, onExit } = {}) {
|
|
|
63
111
|
utfMouse: false,
|
|
64
112
|
}, true);
|
|
65
113
|
|
|
66
|
-
|
|
67
|
-
|
|
114
|
+
screen.mouseCaptured = true;
|
|
115
|
+
writeSeq(screen, ENABLE_MOUSE_SEQ);
|
|
116
|
+
screen.enableMouse();
|
|
68
117
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Игнорируем в headless/тестах
|
|
76
|
-
}
|
|
118
|
+
// Режимы мыши переотправляются после каждой перерисовки и при ресайзе.
|
|
119
|
+
// Это выглядит избыточным, но без этого часть терминалов перестаёт слать
|
|
120
|
+
// события мыши приложению: попытка убрать переотправку ломала клики целиком.
|
|
121
|
+
// Не удалять без проверки в живом терминале — headless-тесты этого не ловят.
|
|
122
|
+
function restoreMouse() {
|
|
123
|
+
if (screen.mouseCaptured) writeSeq(screen, ENABLE_MOUSE_SEQ);
|
|
77
124
|
}
|
|
78
125
|
|
|
79
|
-
|
|
80
|
-
screen.
|
|
81
|
-
|
|
82
|
-
// Отправляем escape-последовательности повторно при перерендере/ресайзе экрана
|
|
83
|
-
screen.on("render", sendEnableMouse);
|
|
84
|
-
screen.on("resize", sendEnableMouse);
|
|
126
|
+
screen.on("render", restoreMouse);
|
|
127
|
+
screen.on("resize", restoreMouse);
|
|
85
128
|
|
|
86
129
|
// Пробрасываем событие click на уровне экрана при mouseup
|
|
87
130
|
screen.on("mouseup", (data) => {
|
|
@@ -91,10 +134,7 @@ export function createScreen({ theme, onExit } = {}) {
|
|
|
91
134
|
// Обработка закрытия терминала или аварийного прерывания
|
|
92
135
|
function cleanExit() {
|
|
93
136
|
try {
|
|
94
|
-
|
|
95
|
-
screen.program.output.write(disableMouseSeq);
|
|
96
|
-
}
|
|
97
|
-
screen.program.disableMouse();
|
|
137
|
+
setMouseCapture(screen, false);
|
|
98
138
|
onExit?.();
|
|
99
139
|
} catch {
|
|
100
140
|
// Выходим в любом случае
|