@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.
- package/.env.example +15 -0
- package/CHANGELOG.md +63 -1
- package/CHANGELOG.ru.md +67 -1
- package/README.md +96 -15
- package/README.ru.md +97 -15
- package/package.json +2 -1
- package/src/cli/videoSetup.js +187 -0
- package/src/config.js +6 -0
- package/src/index.js +8 -0
- package/src/state.js +31 -8
- package/src/telegram/dialogs.js +6 -2
- package/src/telegram/formatter.js +1 -1
- package/src/telegram/messages.js +182 -19
- package/src/ui/app.js +293 -73
- package/src/ui/components/chatList.js +159 -101
- package/src/ui/components/chatView.js +454 -109
- package/src/ui/components/header.js +2 -1
- package/src/ui/components/inputBox.js +14 -5
- package/src/ui/components/modals/actionModal.js +11 -14
- package/src/ui/components/modals/chatInfoModal.js +3 -13
- package/src/ui/components/modals/confirmModal.js +3 -13
- package/src/ui/components/modals/fileModal.js +6 -13
- package/src/ui/components/modals/filePickerModal.js +3 -13
- package/src/ui/components/modals/helpModal.js +14 -18
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/modals/videoPlayerModal.js +274 -0
- package/src/ui/components/statusBar.js +2 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +168 -20
- package/src/ui/theme.js +1 -0
- package/src/utils/image.js +318 -14
- package/src/utils/mouse.js +62 -75
- package/src/utils/video.js +495 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
183
|
-
let
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
|
|
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
|
|
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
|
-
|
|
275
|
-
|
|
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]) {
|