@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.
@@ -294,8 +294,11 @@ export function rgbaToHalfBlockBlessed(data, width, height) {
294
294
  * @returns {string}
295
295
  */
296
296
  export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
297
- if (cacheKey && imagePreviewCache.has(cacheKey)) {
298
- return imagePreviewCache.get(cacheKey);
297
+ // Размер входит в ключ: одно и то же фото рисуется и миниатюрой в ленте, и на весь
298
+ // экран в просмотрщике. Без размера полноэкранный рендер подменял бы превью в ленте.
299
+ const key = cacheKey ? `${cacheKey}@${maxWidth}x${maxHeight}` : null;
300
+ if (key && imagePreviewCache.has(key)) {
301
+ return imagePreviewCache.get(key);
299
302
  }
300
303
 
301
304
  try {
@@ -304,12 +307,12 @@ export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHei
304
307
  const resized = resizeRgba(decoded.data, decoded.width, decoded.height, dstW, dstH);
305
308
  const blessedText = rgbaToHalfBlockBlessed(resized, dstW, dstH);
306
309
 
307
- if (cacheKey) {
310
+ if (key) {
308
311
  if (imagePreviewCache.size >= MAX_CACHE_SIZE) {
309
312
  const firstKey = imagePreviewCache.keys().next().value;
310
313
  imagePreviewCache.delete(firstKey);
311
314
  }
312
- imagePreviewCache.set(cacheKey, blessedText);
315
+ imagePreviewCache.set(key, blessedText);
313
316
  }
314
317
 
315
318
  return blessedText;
@@ -330,10 +333,7 @@ export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHei
330
333
  export function renderStrippedThumbnail(strippedBytes, { maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
331
334
  if (!strippedBytes || strippedBytes.length < 3) return "";
332
335
 
333
- if (cacheKey && imagePreviewCache.has(cacheKey)) {
334
- return imagePreviewCache.get(cacheKey);
335
- }
336
-
336
+ // Кэш проверяет renderImageBuffer: только он знает итоговый ключ с размером
337
337
  try {
338
338
  const jpgBuf = strippedPhotoToJpg(strippedBytes);
339
339
  return renderImageBuffer(jpgBuf, { mimeType: "image/jpeg", maxWidth, maxHeight, cacheKey });
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Утилиты для обработки координат и сценариев взаимодействия с мышью в TUI.
3
+ */
4
+
5
+ import unicode from "neo-blessed/lib/unicode.js";
6
+
7
+ /** Пиктограммы и эмодзи, занимающие две ячейки терминала. */
8
+ const EMOJI_REGEX = /\p{Extended_Pictographic}/u;
9
+
10
+ /**
11
+ * Вычисляет ширину строки в терминальных ячейках.
12
+ * Учитывает двухъячеечные эмодзи и разметку blessed.
13
+ * @param {string} text
14
+ * @returns {number}
15
+ */
16
+ export function stringCellWidth(text) {
17
+ if (!text) return 0;
18
+ const clean = text.replace(/\{[^{}]+\}/g, "");
19
+ let width = 0;
20
+ for (const char of clean) {
21
+ const byBlessed = unicode.strWidth(char);
22
+ width += EMOJI_REGEX.test(char) ? Math.max(2, byBlessed) : byBlessed;
23
+ }
24
+ return width;
25
+ }
26
+
27
+ /**
28
+ * Отличает клик правой кнопкой от остальных.
29
+ * neo-blessed эмитит одно и то же событие "click" для любой кнопки мыши,
30
+ * поэтому компоненты, где правый клик не имеет смысла, обязаны его отсеивать.
31
+ * @param {{ button?: string }} [data] данные события мыши blessed
32
+ * @returns {boolean}
33
+ */
34
+ export function isRightClick(data) {
35
+ return data?.button === "right";
36
+ }
37
+
38
+ /**
39
+ * Проверяет, попадают ли координаты (x, y) внутрь прямоугольной области.
40
+ * @param {number} x абсолютная или относительная X-координата
41
+ * @param {number} y абсолютная или относительная Y-координата
42
+ * @param {{ left: number, top: number, width: number, height: number }} bounds
43
+ * @returns {boolean}
44
+ */
45
+ export function isInsideBox(x, y, bounds) {
46
+ if (!bounds) return false;
47
+ const { left = 0, top = 0, width = 0, height = 0 } = bounds;
48
+ return x >= left && x < left + width && y >= top && y < top + height;
49
+ }
50
+
51
+ const DEFAULT_TAB_KEYS = ["all", "users", "groups", "channels", "bots", "unread"];
52
+ const DEFAULT_TAB_NAMES = ["1:Все", "2:ЛС", "3:Группы", "4:Каналы", "5:Боты", "6:Непроч"];
53
+
54
+ /**
55
+ * Определяет ключ вкладки диалогов по относительному горизонтальному смещению курсора мыши.
56
+ * @param {number} relativeX смещение по X от левого края строки вкладок (в ячейках)
57
+ * @param {string[]} [tabKeys] ключи вкладок
58
+ * @param {string[]} [tabNames] отображаемые названия вкладок
59
+ * @returns {string|null} ключ выбранной вкладки или null
60
+ */
61
+ export function getTabByCoordinate(relativeX, tabKeys = DEFAULT_TAB_KEYS, tabNames = DEFAULT_TAB_NAMES) {
62
+ if (relativeX < 0) return null;
63
+
64
+ let currentX = 0;
65
+ for (let i = 0; i < tabNames.length; i++) {
66
+ // Каждая вкладка оформляется с одним ведущим и одним замыкающим пробелом
67
+ const tabWidth = stringCellWidth(` ${tabNames[i]} `);
68
+ if (relativeX >= currentX && relativeX < currentX + tabWidth) {
69
+ return tabKeys[i] || null;
70
+ }
71
+ currentX += tabWidth;
72
+ }
73
+
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Находит сообщение в ленте по номеру отображаемой строки с учётом текущей прокрутки.
79
+ * Карту строк строит ChatView при отрисовке ленты — только он знает реальную вёрстку.
80
+ * @param {number} lineIndex индекс строки в буфере ленты (0-based)
81
+ * @param {Array<{ message: object, startLine: number, endLine: number }>} ranges
82
+ * @returns {object|null}
83
+ */
84
+ export function getMessageAtLine(lineIndex, ranges) {
85
+ if (!ranges || ranges.length === 0 || lineIndex < 0) return null;
86
+
87
+ for (const item of ranges) {
88
+ if (lineIndex >= item.startLine && lineIndex <= item.endLine) {
89
+ return item.message;
90
+ }
91
+ }
92
+
93
+ return null;
94
+ }
95
+
96
+ /**
97
+ * Определяет, в какую часть сообщения попал клик: в превью изображения или в остальной текст.
98
+ * @param {number} lineIndex индекс строки в буфере ленты (0-based)
99
+ * @param {number} relativeX смещение по X от левого края ленты (в ячейках)
100
+ * @param {Array<{ message: object, startLine: number, endLine: number, image?: object|null }>} ranges
101
+ * @returns {{ message: object, part: "image"|"body" }|null}
102
+ */
103
+ export function getMessagePartAtPoint(lineIndex, relativeX, ranges) {
104
+ if (!ranges || ranges.length === 0 || lineIndex < 0) return null;
105
+
106
+ for (const item of ranges) {
107
+ if (lineIndex < item.startLine || lineIndex > item.endLine) continue;
108
+
109
+ const image = item.image;
110
+ const insideImage = Boolean(image)
111
+ && lineIndex >= image.startLine && lineIndex <= image.endLine
112
+ && relativeX >= image.left && relativeX < image.right;
113
+
114
+ return { message: item.message, part: insideImage ? "image" : "body" };
115
+ }
116
+
117
+ return null;
118
+ }
119
+
120
+ /**
121
+ * Определяет действие по клику на строку состояния внизу экрана.
122
+ * @param {number} relativeX смещение по X от левого края строки состояния
123
+ * @param {number} [totalWidth=120] общая ширина терминала
124
+ * @returns {"focus"|"select"|"tabs"|"search"|"help"|"actions"|"info"|"quit"|null}
125
+ */
126
+ export function getStatusBarActionAt(relativeX, totalWidth = 120) {
127
+ if (relativeX < 0 || relativeX >= totalWidth) return null;
128
+
129
+ // Сегменты подсказок в строке состояния:
130
+ // [Tab] Панель │ [Enter] Выбрать/Отправить │ [1-6] Вкладки │ [/] Поиск │ [F1] Помощь │ [Ctrl+A] Действия │ [Ctrl+P] Инфо │ [Ctrl+Q] Выход
131
+ const segments = [
132
+ { id: "focus", label: " [Tab] Панель " },
133
+ { id: "select", label: " [Enter] Выбрать/Отправить " },
134
+ { id: "tabs", label: " [1-6] Вкладки " },
135
+ { id: "search", label: " [/] Поиск " },
136
+ { id: "help", label: " [F1] Помощь " },
137
+ { id: "actions", label: " [Ctrl+A] Действия " },
138
+ { id: "info", label: " [Ctrl+P] Инфо " },
139
+ { id: "quit", label: " [Ctrl+Q] Выход" },
140
+ ];
141
+
142
+ let currentX = 0;
143
+ for (const seg of segments) {
144
+ const segWidth = stringCellWidth(seg.label) + 1; // +1 на разделитель "│"
145
+ if (relativeX >= currentX && relativeX < currentX + segWidth) {
146
+ return seg.id;
147
+ }
148
+ currentX += segWidth;
149
+ }
150
+
151
+ return null;
152
+ }
153
+
154
+ /** Ширина логотипа « 🚀 TuiGram» в ячейках — кликабельная зона вызова справки. */
155
+ const HEADER_LOGO_WIDTH = stringCellWidth(" 🚀 TuiGram");
156
+
157
+ /**
158
+ * Определяет действие при клике на верхнюю шапку приложения.
159
+ * @param {number} relativeX смещение по X от левого края шапки
160
+ * @param {number} relativeY смещение по Y от верхнего края шапки (0..3)
161
+ * @param {object} [options]
162
+ * @param {boolean} [options.hasActiveChat=false] есть ли выбранный активный чат
163
+ * @returns {"help"|"info"|"status"|null}
164
+ */
165
+ export function getHeaderActionAt(relativeX, relativeY, { hasActiveChat = false } = {}) {
166
+ // Внутренняя строка 1 (верхняя линия контента): логотип TuiGram, имя пользователя, статус
167
+ if (relativeY === 1) {
168
+ // Контент шапки начинается после рамки, поэтому логотип занимает ячейки 1..HEADER_LOGO
169
+ if (relativeX >= 1 && relativeX <= HEADER_LOGO_WIDTH) {
170
+ return "help";
171
+ }
172
+ return "status";
173
+ }
174
+
175
+ // Внутренняя строка 2 (нижняя линия контента): активный чат
176
+ if (relativeY === 2) {
177
+ if (hasActiveChat) {
178
+ return "info";
179
+ }
180
+ }
181
+
182
+ return null;
183
+ }
184
+
185
+ /**
186
+ * Сегменты подсказок контекстной строки ввода в том порядке и с теми подписями,
187
+ * какими их рисует inputBox.renderContext. Ширины считаются из самих подписей —
188
+ * зашитые вручную координаты разъезжались при любой правке текста.
189
+ */
190
+ const INPUT_HINT_SEGMENTS = [
191
+ { id: null, label: " Введите сообщение... " },
192
+ { id: null, label: "[Enter] Отправить " },
193
+ { id: null, label: "[Ctrl+J] Новая строка " },
194
+ { id: "reply", label: "[Ctrl+R] Ответ " },
195
+ { id: "edit", label: "[Ctrl+E] Правка " },
196
+ { id: "commands", label: "[/] Команды" },
197
+ ];
198
+
199
+ /**
200
+ * Определяет действие при клике на контекстную строку поля ввода.
201
+ * @param {number} relativeX смещение по X от левого края контекстной строки
202
+ * @param {string|null} mode текущий режим ("reply"|"edit"|null)
203
+ * @returns {"cancel"|"reply"|"edit"|"commands"|null}
204
+ */
205
+ export function getInputContextActionAt(relativeX, mode) {
206
+ if (mode === "reply" || mode === "edit") {
207
+ // Любой клик по плашке ответа/редактирования (или по кнопке [Esc: Отмена]) сбрасывает режим
208
+ return "cancel";
209
+ }
210
+
211
+ if (relativeX < 0) return null;
212
+
213
+ let currentX = 0;
214
+ for (const seg of INPUT_HINT_SEGMENTS) {
215
+ const segWidth = stringCellWidth(seg.label);
216
+ if (relativeX >= currentX && relativeX < currentX + segWidth) {
217
+ return seg.id;
218
+ }
219
+ currentX += segWidth;
220
+ }
221
+
222
+ return null;
223
+ }