@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.
@@ -1,10 +1,140 @@
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
+ import { getTabByCoordinate, isRightClick } from "../../utils/mouse.js";
6
7
 
7
- import { getTabByCoordinate } from "../../utils/mouse.js";
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
+ }
8
138
 
9
139
  /**
10
140
  * Создаёт компонент списка диалогов (левая панель).
@@ -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";
@@ -70,6 +197,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
70
197
  }
71
198
 
72
199
  tabsBox.on("click", (data) => {
200
+ if (isRightClick(data)) return;
73
201
  const relX = data.x - (tabsBox.aleft || 0);
74
202
  const tabKey = getTabByCoordinate(relX, TAB_KEYS, TAB_NAMES);
75
203
  if (tabKey) {
@@ -101,7 +229,9 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
101
229
  if (searchBox.getValue() === SEARCH_PLACEHOLDER) {
102
230
  searchBox.setValue("");
103
231
  }
104
- searchBox.focus();
232
+ if (screen.focused !== searchBox) {
233
+ searchBox.focus();
234
+ }
105
235
  screen.render();
106
236
  });
107
237
 
@@ -148,7 +278,8 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
148
278
  list.createItem = (content) => {
149
279
  const item = baseCreateItem(content);
150
280
  item.wrap = false;
151
- item.on("click", () => {
281
+ item.on("click", (data) => {
282
+ if (isRightClick(data)) return;
152
283
  const index = list.getItemIndex(item);
153
284
  if (index !== -1 && currentDialogs[index]) {
154
285
  list.select(index);
@@ -171,98 +302,22 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
171
302
  let currentDialogs = [];
172
303
 
173
304
  /**
174
- * Ширина строки в ячейках терминала по той же модели, которой пользуется
175
- * blessed при отрисовке. Считать через .length нельзя: эмодзи занимают
176
- * две ячейки, а в UTF-16 это одна-две единицы — из-за расхождения строка
177
- * вылезала за край, blessed резал её посреди бейджа непрочитанных и хвост
178
- * оставался залит фоном бейджа.
179
- * @param {string} text
305
+ * Вычисляет реальную доступную ширину для элемента списка диалогов.
180
306
  * @returns {number}
181
307
  */
182
- function cellWidth(text) {
183
- let width = 0;
184
- for (const char of text) {
185
- const byBlessed = unicode.strWidth(char);
186
- // Эмодзи терминал рисует в две ячейки, а blessed считает их за одну.
187
- // Берём максимум: бюджет должен быть верен для обеих моделей, иначе
188
- // строка вылезает за край в реальном терминале.
189
- width += EMOJI.test(char) ? Math.max(2, byBlessed) : byBlessed;
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;
190
319
  }
191
- return width;
192
- }
193
-
194
- /**
195
- * Обрезает строку до заданной ширины В ЯЧЕЙКАХ, добавляя многоточие.
196
- * @param {string} text
197
- * @param {number} maxCells
198
- * @returns {string}
199
- */
200
- function truncate(text, maxCells) {
201
- if (maxCells <= 1) return "";
202
- if (cellWidth(text) <= maxCells) return text;
203
-
204
- let width = 0;
205
- let result = "";
206
- for (const char of text) {
207
- const charCells = cellWidth(char);
208
- if (width + charCells > maxCells - 1) break;
209
- width += charCells;
210
- result += char;
211
- }
212
- return `${result}…`;
213
- }
214
-
215
- /**
216
- * Форматирует элемент диалога в ОДНУ строку.
217
- * blessed.list жёстко задаёт элементам height: 1, поэтому любой перевод строки
218
- * в содержимом теряется без предупреждения.
219
- * @param {object} d
220
- * @param {number} width доступная ширина строки в символах
221
- * @returns {string}
222
- */
223
- function formatDialogItem(d, width) {
224
- const pinIcon = d.pinned ? "📌 " : "";
225
- const typeIcon =
226
- d.type === "channel" ? "📢 " :
227
- d.type === "supergroup" || d.type === "group" ? "👥 " :
228
- d.type === "bot" ? "🤖 " :
229
- d.type === "saved" ? "⭐ " : "👤 ";
230
-
231
- const timeStr = formatChatTime(d.date);
232
- const unreadStr = d.unreadCount > 0 ? ` [${d.unreadCount}]` : "";
233
-
234
- // Всё меряем в ячейках терминала: иконки — это эмодзи переменной ширины
235
- const fixedCells =
236
- cellWidth(pinIcon) + cellWidth(typeIcon) + cellWidth(timeStr) + cellWidth(unreadStr) + 2;
237
- const available = Math.max(10, (width || 40) - fixedCells);
238
-
239
- const rawTitle = d.title || "Чат";
240
- const rawPreview = (d.lastMessage?.text || "").replace(/\s+/g, " ").trim();
241
-
242
- // Название важнее превью: оно получает до 60% ширины (но не меньше 16 ячеек)
243
- // и никогда не больше доступного места — иначе строка вылезет за край и
244
- // бейдж непрочитанных обрежется. Превью занимает остаток и на узких
245
- // терминалах просто исчезает.
246
- const titleMax = Math.min(
247
- cellWidth(rawTitle),
248
- available,
249
- Math.max(16, Math.floor(available * 0.6))
250
- );
251
- const title = truncate(rawTitle, titleMax);
252
- const previewMax = available - cellWidth(title) - 3;
253
- const preview = previewMax >= 6 ? truncate(rawPreview, previewMax) : "";
254
-
255
- const unreadBadge = unreadStr
256
- ? ` ${badge(theme.chatList.itemUnreadBg, theme.chatList.itemUnreadFg, `{bold}${unreadStr}{/bold}`)}`
257
- : "";
258
- const previewPart = preview
259
- ? ` ${fg(theme.chatList.previewFg, `· ${escapeBlessed(preview)}`)}`
260
- : "";
261
- const titlePart = d.pinned
262
- ? fg(theme.chatList.pinnedFg, `{bold}${escapeBlessed(title)}{/bold}`)
263
- : `{bold}${escapeBlessed(title)}{/bold}`;
264
-
265
- return `${pinIcon}${typeIcon}${titlePart}${previewPart} ${fg(theme.chatList.timeFg, timeStr)}${unreadBadge}`;
320
+ return Math.max(10, w || 32);
266
321
  }
267
322
 
268
323
  /**
@@ -271,15 +326,18 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
271
326
  */
272
327
  function setDialogs(dialogs) {
273
328
  currentDialogs = dialogs;
274
- // Элементы списка живут внутри list и ещё на колонку уже из-за скроллбара
275
- // -1 колонка скроллбара, -1 запас: при подсчёте переноса blessed
276
- // прибавляет к ширине часть символов разметки
277
- const width = Math.max(10, list.width - 2);
278
- const items = dialogs.map((d) => formatDialogItem(d, width));
329
+ const width = getAvailableWidth();
330
+ const items = dialogs.map((d) => formatDialogItem(d, width, theme));
279
331
  list.setItems(items);
280
332
  screen.render();
281
333
  }
282
334
 
335
+ screen.on("resize", () => {
336
+ if (currentDialogs.length > 0) {
337
+ setDialogs(currentDialogs);
338
+ }
339
+ });
340
+
283
341
  // Обработка выбора диалога
284
342
  list.on("select", (item, index) => {
285
343
  if (currentDialogs[index]) {