@emaxe/tuigram 1.3.0 → 1.5.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.
@@ -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") {
@@ -145,8 +146,10 @@ export function createInputBox(screen, theme, {
145
146
  });
146
147
 
147
148
  textarea.on("click", () => {
148
- textarea.focus();
149
- screen.render();
149
+ if (screen.focused !== textarea) {
150
+ textarea.focus();
151
+ screen.render();
152
+ }
150
153
  });
151
154
 
152
155
  textarea.key(["enter"], () => {
@@ -229,7 +232,9 @@ export function createInputBox(screen, theme, {
229
232
  textarea.setValue(target.text);
230
233
  }
231
234
  renderContext();
232
- textarea.focus();
235
+ if (screen.focused !== textarea) {
236
+ textarea.focus();
237
+ }
233
238
  },
234
239
  /**
235
240
  * Текущий режим ввода — нужен, чтобы отправить файл ответом.
@@ -245,7 +250,11 @@ export function createInputBox(screen, theme, {
245
250
  textarea.setValue("");
246
251
  screen.render();
247
252
  },
248
- focus: () => textarea.focus(),
253
+ focus: () => {
254
+ if (screen.focused !== textarea) {
255
+ textarea.focus();
256
+ }
257
+ },
249
258
  /**
250
259
  * Завершает режим ввода, отдавая фокус предыдущей панели.
251
260
  * Нужно вызывать перед открытием модального окна: иначе textarea по blur
@@ -1,8 +1,10 @@
1
1
  import blessed from "neo-blessed";
2
2
  import { escapeBlessed } from "../../../telegram/formatter.js";
3
3
  import { fg } from "../../theme.js";
4
+ import { isMessageVideo } from "../../../utils/video.js";
4
5
 
5
- import { isInsideBox } from "../../../utils/mouse.js";
6
+ import { isRightClick } from "../../../utils/mouse.js";
7
+ import { bindOutsideClickClose } from "../../modalMouse.js";
6
8
 
7
9
  /**
8
10
  * Создаёт модальное окно контекстных действий над сообщением.
@@ -72,7 +74,8 @@ export function createActionModal(screen, theme, { onAction } = {}) {
72
74
  const baseCreateItem = list.createItem.bind(list);
73
75
  list.createItem = (content) => {
74
76
  const item = baseCreateItem(content);
75
- item.on("click", () => {
77
+ item.on("click", (data) => {
78
+ if (isRightClick(data)) return;
76
79
  const index = list.getItemIndex(item);
77
80
  const action = currentActions[index];
78
81
  if (action && currentMsg) {
@@ -109,6 +112,10 @@ export function createActionModal(screen, theme, { onAction } = {}) {
109
112
  { id: "reply", label: "↩️ Ответить (Reply)" },
110
113
  ];
111
114
 
115
+ if (isMessageVideo(msg)) {
116
+ currentActions.push({ id: "play_video", label: "▶️ Воспроизвести видео" });
117
+ }
118
+
112
119
  if (msg.out) {
113
120
  currentActions.push({ id: "edit", label: "✏️ Редактировать текст" });
114
121
  }
@@ -128,6 +135,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
128
135
 
129
136
  list.setItems(currentActions.map((a) => a.label));
130
137
  previousFocus = screen.focused;
138
+ armOutsideClose();
131
139
  modal.show();
132
140
  modal.setFront();
133
141
  list.focus();
@@ -146,18 +154,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
146
154
  list.key(["escape", "q"], hide);
147
155
 
148
156
  // Закрытие при клике мышью мимо модального окна
149
- screen.on("click", (data) => {
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
- });
157
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
161
158
 
162
159
  return {
163
160
  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 { isInsideBox } from "../../../utils/mouse.js";
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.on("click", (data) => {
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 { isInsideBox } from "../../../utils/mouse.js";
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.on("click", (data) => {
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 { isInsideBox } from "../../../utils/mouse.js";
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
- screen.on("click", (data) => {
365
- if (!modal.visible || picker.modal.visible) return;
366
- const inside = isInsideBox(data.x, data.y, {
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 { isInsideBox } from "../../../utils/mouse.js";
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.on("click", (data) => {
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 { isInsideBox } from "../../../utils/mouse.js";
2
+ import { bindOutsideClickClose } from "../../modalMouse.js";
3
3
 
4
4
  /**
5
5
  * Создаёт модальное окно справки по всем горячим клавишам и возможностям.
@@ -44,16 +44,22 @@ 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}Клик по сообщению${K_END} Открыть меню действий над сообщением
56
+ ${K_CYAN}Левый клик по сообщению${K_END} Выделить сообщение (помечается полосой ▌ слева)
57
+ ${K_CYAN}Правый клик по сообщению${K_END} Меню действий над сообщением
58
+ ${K_CYAN}Клик по превью фото/видео${K_END}Открыть изображение или воспроизвести видео
59
+ ${K_CYAN} [Space] / [r]${K_END} — в видеоплеере: пауза / перезапуск
56
60
  ${K_CYAN}Клик по вкладкам/кнопкам${K_END} Переключение фильтров и вызов действий
61
+ ${K_GRAY}macOS Terminal.app перехватывает правый клик — там пользуйтесь [Enter] или [Ctrl+A]${K_END}
62
+ ${K_CYAN}[F12]${K_END} Отдать мышь терминалу, чтобы выделить и скопировать текст
57
63
 
58
64
  {bold}Вкладки фильтрации диалогов (нажмите цифру в списке чатов):{/bold}
59
65
  ${K_YELLOW}[1]${K_END} Все чаты ${K_YELLOW}[2]${K_END} Личные (ЛС) ${K_YELLOW}[3]${K_END} Группы
@@ -63,8 +69,8 @@ export function createHelpModal(screen, theme) {
63
69
  {bold}Работа с сообщениями:{/bold}
64
70
  ${K_GREEN}[Enter]${K_END} Отправить набранный текст
65
71
  ${K_GREEN}[Ctrl+J]${K_END} Перенос строки без отправки
66
- ${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на последнее сообщение
67
- ${K_GREEN}[Ctrl+E]${K_END} Редактировать своё последнее сообщение
72
+ ${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на выделенное, иначе — на последнее
73
+ ${K_GREEN}[Ctrl+E]${K_END} Редактировать выделенное своё, иначе — последнее своё
68
74
  ${K_GREEN}[Ctrl+A]${K_END} Контекстное меню действий (Реакции, Удаление, Скачивание)
69
75
  ${K_GREEN}[Ctrl+O]${K_END} Отправить файл / картинку / документ
70
76
  ${K_GREEN} [Ctrl+F]${K_END} — в окне отправки: обзор файлов
@@ -122,6 +128,7 @@ export function createHelpModal(screen, theme) {
122
128
 
123
129
  function show() {
124
130
  previousFocus = screen.focused;
131
+ armOutsideClose();
125
132
  modal.show();
126
133
  modal.setFront();
127
134
  closeBtn.focus();
@@ -134,18 +141,7 @@ export function createHelpModal(screen, theme) {
134
141
  closeBtn.key(["escape", "q", "f1"], hide);
135
142
 
136
143
  // Закрытие при клике мышью мимо модального окна
137
- screen.on("click", (data) => {
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
- });
144
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
149
145
 
150
146
  return {
151
147
  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
+ }