@emaxe/tuigram 1.1.0 → 1.3.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,236 @@
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
+ * Проверяет, попадают ли координаты (x, y) внутрь прямоугольной области.
29
+ * @param {number} x абсолютная или относительная X-координата
30
+ * @param {number} y абсолютная или относительная Y-координата
31
+ * @param {{ left: number, top: number, width: number, height: number }} bounds
32
+ * @returns {boolean}
33
+ */
34
+ export function isInsideBox(x, y, bounds) {
35
+ if (!bounds) return false;
36
+ const { left = 0, top = 0, width = 0, height = 0 } = bounds;
37
+ return x >= left && x < left + width && y >= top && y < top + height;
38
+ }
39
+
40
+ const DEFAULT_TAB_KEYS = ["all", "users", "groups", "channels", "bots", "unread"];
41
+ const DEFAULT_TAB_NAMES = ["1:Все", "2:ЛС", "3:Группы", "4:Каналы", "5:Боты", "6:Непроч"];
42
+
43
+ /**
44
+ * Определяет ключ вкладки диалогов по относительному горизонтальному смещению курсора мыши.
45
+ * @param {number} relativeX смещение по X от левого края строки вкладок (в ячейках)
46
+ * @param {string[]} [tabKeys] ключи вкладок
47
+ * @param {string[]} [tabNames] отображаемые названия вкладок
48
+ * @returns {string|null} ключ выбранной вкладки или null
49
+ */
50
+ export function getTabByCoordinate(relativeX, tabKeys = DEFAULT_TAB_KEYS, tabNames = DEFAULT_TAB_NAMES) {
51
+ if (relativeX < 0) return null;
52
+
53
+ let currentX = 0;
54
+ for (let i = 0; i < tabNames.length; i++) {
55
+ // Каждая вкладка оформляется с одним ведущим и одним замыкающим пробелом
56
+ const tabWidth = stringCellWidth(` ${tabNames[i]} `);
57
+ if (relativeX >= currentX && relativeX < currentX + tabWidth) {
58
+ return tabKeys[i] || null;
59
+ }
60
+ currentX += tabWidth;
61
+ }
62
+
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Строит карту диапазонов строк для каждого сообщения в ленте чата.
68
+ * Нужна для точного определения сообщения, по которому кликнули мышью в ChatView.
69
+ * @param {Array<object>} messages список сообщений чата
70
+ * @returns {Array<{ message: object, startLine: number, endLine: number }>}
71
+ */
72
+ export function buildMessageLineRanges(messages) {
73
+ if (!messages || messages.length === 0) return [];
74
+
75
+ const ranges = [];
76
+ let currentLine = 0;
77
+ let lastDateString = "";
78
+
79
+ for (const msg of messages) {
80
+ // 1. Проверяем разделитель дат (если дата изменилась)
81
+ const dateDivider = msg.date ? new Date(msg.date).toLocaleDateString("ru-RU") : "";
82
+ if (dateDivider && dateDivider !== lastDateString) {
83
+ // Форматтер добавляет: \n ─────── дата ─────── \n\n (3 строки)
84
+ currentLine += 3;
85
+ lastDateString = dateDivider;
86
+ }
87
+
88
+ const startLine = currentLine;
89
+
90
+ // 2. Строка автора/времени
91
+ currentLine += 1;
92
+
93
+ // 3. Блок ответа (Reply), если есть
94
+ if (msg.replyToMsgId) {
95
+ currentLine += 1;
96
+ }
97
+
98
+ // 4. Тело сообщения (текст + медиа-описание + превью)
99
+ let bodyLinesCount = 1;
100
+ const parts = [];
101
+ if (msg.mediaDescription) parts.push(msg.mediaDescription);
102
+ if (msg.imagePreview) parts.push(msg.imagePreview);
103
+ if (msg.text) parts.push(msg.text);
104
+
105
+ if (parts.length > 0) {
106
+ const fullBody = parts.join("\n");
107
+ bodyLinesCount = fullBody.split("\n").length;
108
+ }
109
+ currentLine += bodyLinesCount;
110
+
111
+ // 5. Реакции, если есть
112
+ if (msg.reactions && msg.reactions.length > 0) {
113
+ currentLine += 1;
114
+ }
115
+
116
+ // 6. Замыкающий отступ между сообщениями (\n\n)
117
+ const endLine = currentLine;
118
+ currentLine += 2;
119
+
120
+ ranges.push({
121
+ message: msg,
122
+ startLine,
123
+ endLine,
124
+ });
125
+ }
126
+
127
+ return ranges;
128
+ }
129
+
130
+ /**
131
+ * Находит сообщение в ленте по номеру отображаемой строки с учётом текущей прокрутки.
132
+ * @param {number} lineIndex индекс строки в буфере ленты (0-based)
133
+ * @param {Array<{ message: object, startLine: number, endLine: number }>} ranges
134
+ * @returns {object|null}
135
+ */
136
+ export function getMessageAtLine(lineIndex, ranges) {
137
+ if (!ranges || ranges.length === 0 || lineIndex < 0) return null;
138
+
139
+ for (const item of ranges) {
140
+ if (lineIndex >= item.startLine && lineIndex <= item.endLine) {
141
+ return item.message;
142
+ }
143
+ }
144
+
145
+ return null;
146
+ }
147
+
148
+ /**
149
+ * Определяет действие по клику на строку состояния внизу экрана.
150
+ * @param {number} relativeX смещение по X от левого края строки состояния
151
+ * @param {number} [totalWidth=120] общая ширина терминала
152
+ * @returns {"focus"|"select"|"tabs"|"search"|"help"|"actions"|"info"|"quit"|null}
153
+ */
154
+ export function getStatusBarActionAt(relativeX, totalWidth = 120) {
155
+ if (relativeX < 0 || relativeX >= totalWidth) return null;
156
+
157
+ // Сегменты подсказок в строке состояния:
158
+ // [Tab] Панель │ [Enter] Выбрать/Отправить │ [1-6] Вкладки │ [/] Поиск │ [F1] Помощь │ [Ctrl+A] Действия │ [Ctrl+P] Инфо │ [Ctrl+Q] Выход
159
+ const segments = [
160
+ { id: "focus", label: " [Tab] Панель " },
161
+ { id: "select", label: " [Enter] Выбрать/Отправить " },
162
+ { id: "tabs", label: " [1-6] Вкладки " },
163
+ { id: "search", label: " [/] Поиск " },
164
+ { id: "help", label: " [F1] Помощь " },
165
+ { id: "actions", label: " [Ctrl+A] Действия " },
166
+ { id: "info", label: " [Ctrl+P] Инфо " },
167
+ { id: "quit", label: " [Ctrl+Q] Выход" },
168
+ ];
169
+
170
+ let currentX = 0;
171
+ for (const seg of segments) {
172
+ const segWidth = stringCellWidth(seg.label) + 1; // +1 на разделитель "│"
173
+ if (relativeX >= currentX && relativeX < currentX + segWidth) {
174
+ return seg.id;
175
+ }
176
+ currentX += segWidth;
177
+ }
178
+
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Определяет действие при клике на верхнюю шапку приложения.
184
+ * @param {number} relativeX смещение по X от левого края шапки
185
+ * @param {number} relativeY смещение по Y от верхнего края шапки (0..3)
186
+ * @param {object} [options]
187
+ * @param {boolean} [options.hasActiveChat=false] есть ли выбранный активный чат
188
+ * @returns {"help"|"info"|"status"|null}
189
+ */
190
+ export function getHeaderActionAt(relativeX, relativeY, { hasActiveChat = false } = {}) {
191
+ // Внутренняя строка 1 (верхняя линия контента): логотип TuiGram, имя пользователя, статус
192
+ if (relativeY === 1) {
193
+ if (relativeX >= 1 && relativeX <= 14) {
194
+ return "help";
195
+ }
196
+ return "status";
197
+ }
198
+
199
+ // Внутренняя строка 2 (нижняя линия контента): активный чат
200
+ if (relativeY === 2) {
201
+ if (hasActiveChat) {
202
+ return "info";
203
+ }
204
+ }
205
+
206
+ return null;
207
+ }
208
+
209
+ /**
210
+ * Определяет действие при клике на контекстную строку поля ввода.
211
+ * @param {number} relativeX смещение по X от левого края контекстной строки
212
+ * @param {string|null} mode текущий режим ("reply"|"edit"|null)
213
+ * @returns {"cancel"|"reply"|"edit"|"commands"|null}
214
+ */
215
+ export function getInputContextActionAt(relativeX, mode) {
216
+ if (mode === "reply" || mode === "edit") {
217
+ // Любой клик по плашке ответа/редактирования (или по кнопке [Esc: Отмена]) сбрасывает режим
218
+ return "cancel";
219
+ }
220
+
221
+ if (relativeX < 0) return null;
222
+
223
+ // Подсказки в обычном режиме:
224
+ // Введите сообщение... [Enter] Отправить [Ctrl+J] Новая строка [Ctrl+R] Ответ [Ctrl+E] Правка [/] Команды
225
+ if (relativeX >= 55 && relativeX < 73) {
226
+ return "reply";
227
+ }
228
+ if (relativeX >= 73 && relativeX < 91) {
229
+ return "edit";
230
+ }
231
+ if (relativeX >= 91) {
232
+ return "commands";
233
+ }
234
+
235
+ return null;
236
+ }