@emaxe/tuigram 1.4.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.
@@ -1,11 +1,141 @@
1
1
  import blessed from "neo-blessed";
2
2
  import { formatChatTime } from "../../utils/time.js";
3
3
  import { escapeBlessed } from "../../telegram/formatter.js";
4
- import { fg, badge } from "../theme.js";
4
+ import { fg, badge, getTheme } from "../theme.js";
5
5
  import unicode from "neo-blessed/lib/unicode.js";
6
-
7
6
  import { getTabByCoordinate, isRightClick } from "../../utils/mouse.js";
8
7
 
8
+ /** Пиктограммы и эмодзи, занимающие две ячейки терминала. */
9
+ const WIDE_CHAR = /\p{Extended_Pictographic}|[\u{1F000}-\u{1FAFF}]|[\u{2600}-\u{27BF}]/u;
10
+
11
+ /**
12
+ * Ширина строки в ячейках терминала по той же модели, которой пользуется
13
+ * blessed при отрисовке. Считать через .length нельзя: эмодзи занимают
14
+ * две ячейки, а в UTF-16 это одна-две единицы — из-за расхождения строка
15
+ * вылезала за край, blessed резал её посреди бейджа непрочитанных и хвост
16
+ * оставался залит фоном бейджа.
17
+ * @param {string} text
18
+ * @returns {number}
19
+ */
20
+ export function cellWidth(text) {
21
+ if (!text) return 0;
22
+ let width = 0;
23
+ for (const char of text) {
24
+ const byBlessed = unicode.strWidth(char);
25
+ // Эмодзи терминал рисует в две ячейки, а blessed считает их за одну.
26
+ // Берём максимум: бюджет должен быть верен для обеих моделей, иначе
27
+ // строка вылезает за край в реальном терминале.
28
+ width += WIDE_CHAR.test(char) ? Math.max(2, byBlessed) : byBlessed;
29
+ }
30
+ return width;
31
+ }
32
+
33
+ /**
34
+ * Обрезает строку до заданной ширины в ячейках терминала с добавлением многоточия.
35
+ * @param {string} text
36
+ * @param {number} maxCells
37
+ * @returns {string}
38
+ */
39
+ export function truncate(text, maxCells) {
40
+ if (maxCells <= 0) return "";
41
+ if (cellWidth(text) <= maxCells) return text;
42
+ if (maxCells === 1) return "…";
43
+
44
+ let width = 0;
45
+ let result = "";
46
+ for (const char of text) {
47
+ const charCells = cellWidth(char);
48
+ if (width + charCells > maxCells - 1) break;
49
+ width += charCells;
50
+ result += char;
51
+ }
52
+ return `${result}…`;
53
+ }
54
+
55
+ /**
56
+ * Форматирует элемент диалога в одну строку фиксированной ширины.
57
+ * Бейдж непрочитанных сообщений и время всегда прижаты максимально вправо
58
+ * в единой ровной колонке, а название диалога и превью сообщения обрезаются
59
+ * при нехватке места.
60
+ * @param {object} d данные диалога
61
+ * @param {number} width доступная ширина строки в ячейках
62
+ * @param {object} [theme] активная тема оформления
63
+ * @returns {string}
64
+ */
65
+ export function formatDialogItem(d, width, theme = getTheme("default")) {
66
+ const totalWidth = width || 40;
67
+ const pinIcon = d.pinned ? "📌 " : "";
68
+ const typeIcon =
69
+ d.type === "channel" ? "📢 " :
70
+ d.type === "supergroup" || d.type === "group" ? "👥 " :
71
+ d.type === "bot" ? "🤖 " :
72
+ d.type === "saved" ? "⭐ " : "👤 ";
73
+
74
+ const timeStr = formatChatTime(d.date);
75
+ const unreadCount = Number(d.unreadCount) || 0;
76
+ const hasUnread = unreadCount > 0;
77
+ const unreadStr = hasUnread ? (unreadCount > 99 ? "[99+]" : `[${unreadCount}]`) : "";
78
+
79
+ // 1. Формируем правый блок: время и бейдж непрочитанных (при наличии)
80
+ let rightPart = "";
81
+ let rightCells = 0;
82
+
83
+ if (hasUnread) {
84
+ const badgeEl = badge(theme.chatList.itemUnreadBg, theme.chatList.itemUnreadFg, `{bold}${unreadStr}{/bold}`);
85
+ if (timeStr) {
86
+ rightPart = `${fg(theme.chatList.timeFg, timeStr)} ${badgeEl}`;
87
+ rightCells = cellWidth(timeStr) + 1 + cellWidth(unreadStr);
88
+ } else {
89
+ rightPart = badgeEl;
90
+ rightCells = cellWidth(unreadStr);
91
+ }
92
+ } else if (timeStr) {
93
+ rightPart = fg(theme.chatList.timeFg, timeStr);
94
+ rightCells = cellWidth(timeStr);
95
+ }
96
+
97
+ // 2. Рассчитываем доступную ширину для левой части
98
+ const maxLeftCells = Math.max(0, totalWidth - rightCells - (rightCells > 0 ? 1 : 0));
99
+ const prefix = `${pinIcon}${typeIcon}`;
100
+ const prefixCells = cellWidth(prefix);
101
+ const maxContentCells = Math.max(0, maxLeftCells - prefixCells);
102
+
103
+ const rawTitle = d.title || "Чат";
104
+ const rawPreview = (d.lastMessage?.text || "").replace(/\s+/g, " ").trim();
105
+
106
+ let title = "";
107
+ let preview = "";
108
+
109
+ if (cellWidth(rawTitle) > maxContentCells) {
110
+ // Название не вмещается полностью — обрезаем с троеточием, превью опускаем
111
+ title = truncate(rawTitle, maxContentCells);
112
+ } else {
113
+ // Название вместилось полностью
114
+ title = rawTitle;
115
+ const remainingForPreview = maxContentCells - cellWidth(title);
116
+ // " · " занимает 3 ячейки, поэтому для текста превью нужно хотя бы ещё 3 ячейки
117
+ if (rawPreview && remainingForPreview >= 6) {
118
+ preview = truncate(rawPreview, remainingForPreview - 3);
119
+ }
120
+ }
121
+
122
+ const previewPart = preview
123
+ ? ` ${fg(theme.chatList.previewFg, `· ${escapeBlessed(preview)}`)}`
124
+ : "";
125
+ const titlePart = d.pinned
126
+ ? fg(theme.chatList.pinnedFg, `{bold}${escapeBlessed(title)}{/bold}`)
127
+ : `{bold}${escapeBlessed(title)}{/bold}`;
128
+
129
+ const leftPart = `${prefix}${titlePart}${previewPart}`;
130
+ const leftCells = prefixCells + cellWidth(title) + (preview ? 3 + cellWidth(preview) : 0);
131
+
132
+ // 3. Выравнивание: дополняем пробелами, чтобы правый блок был прижат к правому краю
133
+ const paddingCells = Math.max(rightCells > 0 && leftCells > 0 ? 1 : 0, totalWidth - leftCells - rightCells);
134
+ const padding = " ".repeat(paddingCells);
135
+
136
+ return `${leftPart}${padding}${rightPart}`;
137
+ }
138
+
9
139
  /**
10
140
  * Создаёт компонент списка диалогов (левая панель).
11
141
  * @param {blessed.Widgets.Screen} screen
@@ -51,9 +181,6 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
51
181
  },
52
182
  });
53
183
 
54
- /** Пиктограммы, которые терминал рисует в две ячейки. */
55
- const EMOJI = /\p{Extended_Pictographic}/u;
56
-
57
184
  const TAB_KEYS = ["all", "users", "groups", "channels", "bots", "unread"];
58
185
  const TAB_NAMES = ["1:Все", "2:ЛС", "3:Группы", "4:Каналы", "5:Боты", "6:Непроч"];
59
186
  let currentTab = "all";
@@ -102,7 +229,9 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
102
229
  if (searchBox.getValue() === SEARCH_PLACEHOLDER) {
103
230
  searchBox.setValue("");
104
231
  }
105
- searchBox.focus();
232
+ if (screen.focused !== searchBox) {
233
+ searchBox.focus();
234
+ }
106
235
  screen.render();
107
236
  });
108
237
 
@@ -173,98 +302,22 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
173
302
  let currentDialogs = [];
174
303
 
175
304
  /**
176
- * Ширина строки в ячейках терминала по той же модели, которой пользуется
177
- * blessed при отрисовке. Считать через .length нельзя: эмодзи занимают
178
- * две ячейки, а в UTF-16 это одна-две единицы — из-за расхождения строка
179
- * вылезала за край, blessed резал её посреди бейджа непрочитанных и хвост
180
- * оставался залит фоном бейджа.
181
- * @param {string} text
305
+ * Вычисляет реальную доступную ширину для элемента списка диалогов.
182
306
  * @returns {number}
183
307
  */
184
- function cellWidth(text) {
185
- let width = 0;
186
- for (const char of text) {
187
- const byBlessed = unicode.strWidth(char);
188
- // Эмодзи терминал рисует в две ячейки, а blessed считает их за одну.
189
- // Берём максимум: бюджет должен быть верен для обеих моделей, иначе
190
- // строка вылезает за край в реальном терминале.
191
- width += EMOJI.test(char) ? Math.max(2, byBlessed) : byBlessed;
192
- }
193
- return width;
194
- }
195
-
196
- /**
197
- * Обрезает строку до заданной ширины В ЯЧЕЙКАХ, добавляя многоточие.
198
- * @param {string} text
199
- * @param {number} maxCells
200
- * @returns {string}
201
- */
202
- function truncate(text, maxCells) {
203
- if (maxCells <= 1) return "";
204
- if (cellWidth(text) <= maxCells) return text;
205
-
206
- let width = 0;
207
- let result = "";
208
- for (const char of text) {
209
- const charCells = cellWidth(char);
210
- if (width + charCells > maxCells - 1) break;
211
- width += charCells;
212
- result += char;
308
+ function getAvailableWidth() {
309
+ let w = 0;
310
+ if (typeof list.width === "number" && list.width > 0) {
311
+ // list.width ширина списка внутри рамки контейнера; -1 на полосу скроллбара
312
+ w = list.width - 1;
313
+ } else if (typeof container.width === "number" && container.width > 0) {
314
+ // container.width: -2 рамка, -1 скроллбар
315
+ w = container.width - 3;
316
+ } else if (screen?.cols > 0) {
317
+ // Контейнер 35% от экрана: -2 рамка, -1 скроллбар
318
+ w = Math.floor(screen.cols * 0.35) - 3;
213
319
  }
214
- return `${result}…`;
215
- }
216
-
217
- /**
218
- * Форматирует элемент диалога в ОДНУ строку.
219
- * blessed.list жёстко задаёт элементам height: 1, поэтому любой перевод строки
220
- * в содержимом теряется без предупреждения.
221
- * @param {object} d
222
- * @param {number} width доступная ширина строки в символах
223
- * @returns {string}
224
- */
225
- function formatDialogItem(d, width) {
226
- const pinIcon = d.pinned ? "📌 " : "";
227
- const typeIcon =
228
- d.type === "channel" ? "📢 " :
229
- d.type === "supergroup" || d.type === "group" ? "👥 " :
230
- d.type === "bot" ? "🤖 " :
231
- d.type === "saved" ? "⭐ " : "👤 ";
232
-
233
- const timeStr = formatChatTime(d.date);
234
- const unreadStr = d.unreadCount > 0 ? ` [${d.unreadCount}]` : "";
235
-
236
- // Всё меряем в ячейках терминала: иконки — это эмодзи переменной ширины
237
- const fixedCells =
238
- cellWidth(pinIcon) + cellWidth(typeIcon) + cellWidth(timeStr) + cellWidth(unreadStr) + 2;
239
- const available = Math.max(10, (width || 40) - fixedCells);
240
-
241
- const rawTitle = d.title || "Чат";
242
- const rawPreview = (d.lastMessage?.text || "").replace(/\s+/g, " ").trim();
243
-
244
- // Название важнее превью: оно получает до 60% ширины (но не меньше 16 ячеек)
245
- // и никогда не больше доступного места — иначе строка вылезет за край и
246
- // бейдж непрочитанных обрежется. Превью занимает остаток и на узких
247
- // терминалах просто исчезает.
248
- const titleMax = Math.min(
249
- cellWidth(rawTitle),
250
- available,
251
- Math.max(16, Math.floor(available * 0.6))
252
- );
253
- const title = truncate(rawTitle, titleMax);
254
- const previewMax = available - cellWidth(title) - 3;
255
- const preview = previewMax >= 6 ? truncate(rawPreview, previewMax) : "";
256
-
257
- const unreadBadge = unreadStr
258
- ? ` ${badge(theme.chatList.itemUnreadBg, theme.chatList.itemUnreadFg, `{bold}${unreadStr}{/bold}`)}`
259
- : "";
260
- const previewPart = preview
261
- ? ` ${fg(theme.chatList.previewFg, `· ${escapeBlessed(preview)}`)}`
262
- : "";
263
- const titlePart = d.pinned
264
- ? fg(theme.chatList.pinnedFg, `{bold}${escapeBlessed(title)}{/bold}`)
265
- : `{bold}${escapeBlessed(title)}{/bold}`;
266
-
267
- return `${pinIcon}${typeIcon}${titlePart}${previewPart} ${fg(theme.chatList.timeFg, timeStr)}${unreadBadge}`;
320
+ return Math.max(10, w || 32);
268
321
  }
269
322
 
270
323
  /**
@@ -273,15 +326,18 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
273
326
  */
274
327
  function setDialogs(dialogs) {
275
328
  currentDialogs = dialogs;
276
- // Элементы списка живут внутри list и ещё на колонку уже из-за скроллбара
277
- // -1 колонка скроллбара, -1 запас: при подсчёте переноса blessed
278
- // прибавляет к ширине часть символов разметки
279
- const width = Math.max(10, list.width - 2);
280
- const items = dialogs.map((d) => formatDialogItem(d, width));
329
+ const width = getAvailableWidth();
330
+ const items = dialogs.map((d) => formatDialogItem(d, width, theme));
281
331
  list.setItems(items);
282
332
  screen.render();
283
333
  }
284
334
 
335
+ screen.on("resize", () => {
336
+ if (currentDialogs.length > 0) {
337
+ setDialogs(currentDialogs);
338
+ }
339
+ });
340
+
285
341
  // Обработка выбора диалога
286
342
  list.on("select", (item, index) => {
287
343
  if (currentDialogs[index]) {