@emaxe/tuigram 1.0.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,295 @@
1
+ import blessed from "neo-blessed";
2
+ import { formatChatTime } from "../../utils/time.js";
3
+ import { escapeBlessed } from "../../telegram/formatter.js";
4
+ import { fg, badge } from "../theme.js";
5
+ import unicode from "neo-blessed/lib/unicode.js";
6
+
7
+ /**
8
+ * Создаёт компонент списка диалогов (левая панель).
9
+ * @param {blessed.Widgets.Screen} screen
10
+ * @param {object} theme
11
+ * @param {object} callbacks
12
+ * @param {(dialog: object) => void} callbacks.onSelectDialog
13
+ * @param {(tab: string) => void} callbacks.onTabChange
14
+ * @param {(query: string) => void} callbacks.onSearchChange
15
+ */
16
+ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onSearchChange } = {}) {
17
+ const container = blessed.box({
18
+ parent: screen,
19
+ top: 4,
20
+ left: 0,
21
+ width: "35%",
22
+ bottom: 1,
23
+ border: {
24
+ type: "line",
25
+ },
26
+ style: {
27
+ bg: theme.chatList.bg,
28
+ fg: theme.chatList.fg,
29
+ border: {
30
+ fg: theme.borders.fg,
31
+ },
32
+ },
33
+ });
34
+
35
+ // 1. Вкладки фильтрации сверху списка
36
+ const tabsBox = blessed.box({
37
+ parent: container,
38
+ top: 0,
39
+ left: 0,
40
+ right: 0,
41
+ height: 1,
42
+ tags: true,
43
+ style: {
44
+ bg: theme.tabs.bg,
45
+ fg: theme.tabs.fg,
46
+ },
47
+ });
48
+
49
+ /** Пиктограммы, которые терминал рисует в две ячейки. */
50
+ const EMOJI = /\p{Extended_Pictographic}/u;
51
+
52
+ const TAB_KEYS = ["all", "users", "groups", "channels", "bots", "unread"];
53
+ const TAB_NAMES = ["1:Все", "2:ЛС", "3:Группы", "4:Каналы", "5:Боты", "6:Непроч"];
54
+ let currentTab = "all";
55
+
56
+ function renderTabs() {
57
+ const rendered = TAB_KEYS.map((key, idx) => {
58
+ const name = TAB_NAMES[idx];
59
+ if (key === currentTab) {
60
+ return badge(theme.tabs.activeBg, theme.tabs.activeFg, `{bold} ${name} {/bold}`);
61
+ }
62
+ return fg(theme.tabs.fg, ` ${name} `);
63
+ }).join("");
64
+ tabsBox.setContent(rendered);
65
+ }
66
+
67
+ // 2. Строка поиска / фильтра
68
+ const searchBox = blessed.textbox({
69
+ parent: container,
70
+ top: 1,
71
+ left: 0,
72
+ right: 0,
73
+ height: 1,
74
+ inputOnFocus: true,
75
+ style: {
76
+ bg: theme.search.bg,
77
+ fg: theme.search.fg,
78
+ },
79
+ });
80
+ const SEARCH_PLACEHOLDER = "[/] Поиск чатов...";
81
+ searchBox.setValue(SEARCH_PLACEHOLDER);
82
+
83
+ // 3. Список диалогов
84
+ const list = blessed.list({
85
+ parent: container,
86
+ top: 2,
87
+ left: 0,
88
+ right: 0,
89
+ bottom: 0,
90
+ tags: true,
91
+ keys: true,
92
+ vi: true,
93
+ mouse: true,
94
+ scrollbar: {
95
+ ch: "│",
96
+ style: {
97
+ bg: theme.scrollbar.bg,
98
+ fg: theme.scrollbar.fg,
99
+ },
100
+ },
101
+ style: {
102
+ bg: theme.chatList.bg,
103
+ fg: theme.chatList.fg,
104
+ selected: {
105
+ bg: theme.chatList.selectedBg,
106
+ fg: theme.chatList.selectedFg,
107
+ bold: true,
108
+ },
109
+ item: {
110
+ hover: {
111
+ bg: theme.chatList.itemHoverBg,
112
+ },
113
+ },
114
+ },
115
+ });
116
+
117
+ // Элемент списка — всегда одна строка. По умолчанию blessed переносит
118
+ // не влезающий хвост на вторую строку, которой в элементе высотой 1 просто
119
+ // нет: хвост исчезал, а начатый перед разрывом фон бейджа непрочитанных
120
+ // оставался залитым до края панели. С wrap: false лишнее просто обрезается.
121
+ const baseCreateItem = list.createItem.bind(list);
122
+ list.createItem = (content) => {
123
+ const item = baseCreateItem(content);
124
+ item.wrap = false;
125
+ return item;
126
+ };
127
+
128
+ let currentDialogs = [];
129
+
130
+ /**
131
+ * Ширина строки в ячейках терминала по той же модели, которой пользуется
132
+ * blessed при отрисовке. Считать через .length нельзя: эмодзи занимают
133
+ * две ячейки, а в UTF-16 это одна-две единицы — из-за расхождения строка
134
+ * вылезала за край, blessed резал её посреди бейджа непрочитанных и хвост
135
+ * оставался залит фоном бейджа.
136
+ * @param {string} text
137
+ * @returns {number}
138
+ */
139
+ function cellWidth(text) {
140
+ let width = 0;
141
+ for (const char of text) {
142
+ const byBlessed = unicode.strWidth(char);
143
+ // Эмодзи терминал рисует в две ячейки, а blessed считает их за одну.
144
+ // Берём максимум: бюджет должен быть верен для обеих моделей, иначе
145
+ // строка вылезает за край в реальном терминале.
146
+ width += EMOJI.test(char) ? Math.max(2, byBlessed) : byBlessed;
147
+ }
148
+ return width;
149
+ }
150
+
151
+ /**
152
+ * Обрезает строку до заданной ширины В ЯЧЕЙКАХ, добавляя многоточие.
153
+ * @param {string} text
154
+ * @param {number} maxCells
155
+ * @returns {string}
156
+ */
157
+ function truncate(text, maxCells) {
158
+ if (maxCells <= 1) return "";
159
+ if (cellWidth(text) <= maxCells) return text;
160
+
161
+ let width = 0;
162
+ let result = "";
163
+ for (const char of text) {
164
+ const charCells = cellWidth(char);
165
+ if (width + charCells > maxCells - 1) break;
166
+ width += charCells;
167
+ result += char;
168
+ }
169
+ return `${result}…`;
170
+ }
171
+
172
+ /**
173
+ * Форматирует элемент диалога в ОДНУ строку.
174
+ * blessed.list жёстко задаёт элементам height: 1, поэтому любой перевод строки
175
+ * в содержимом теряется без предупреждения.
176
+ * @param {object} d
177
+ * @param {number} width доступная ширина строки в символах
178
+ * @returns {string}
179
+ */
180
+ function formatDialogItem(d, width) {
181
+ const pinIcon = d.pinned ? "📌 " : "";
182
+ const typeIcon =
183
+ d.type === "channel" ? "📢 " :
184
+ d.type === "supergroup" || d.type === "group" ? "👥 " :
185
+ d.type === "bot" ? "🤖 " :
186
+ d.type === "saved" ? "⭐ " : "👤 ";
187
+
188
+ const timeStr = formatChatTime(d.date);
189
+ const unreadStr = d.unreadCount > 0 ? ` [${d.unreadCount}]` : "";
190
+
191
+ // Всё меряем в ячейках терминала: иконки — это эмодзи переменной ширины
192
+ const fixedCells =
193
+ cellWidth(pinIcon) + cellWidth(typeIcon) + cellWidth(timeStr) + cellWidth(unreadStr) + 2;
194
+ const available = Math.max(10, (width || 40) - fixedCells);
195
+
196
+ const rawTitle = d.title || "Чат";
197
+ const rawPreview = (d.lastMessage?.text || "").replace(/\s+/g, " ").trim();
198
+
199
+ // Название важнее превью: оно получает до 60% ширины (но не меньше 16 ячеек)
200
+ // и никогда не больше доступного места — иначе строка вылезет за край и
201
+ // бейдж непрочитанных обрежется. Превью занимает остаток и на узких
202
+ // терминалах просто исчезает.
203
+ const titleMax = Math.min(
204
+ cellWidth(rawTitle),
205
+ available,
206
+ Math.max(16, Math.floor(available * 0.6))
207
+ );
208
+ const title = truncate(rawTitle, titleMax);
209
+ const previewMax = available - cellWidth(title) - 3;
210
+ const preview = previewMax >= 6 ? truncate(rawPreview, previewMax) : "";
211
+
212
+ const unreadBadge = unreadStr
213
+ ? ` ${badge(theme.chatList.itemUnreadBg, theme.chatList.itemUnreadFg, `{bold}${unreadStr}{/bold}`)}`
214
+ : "";
215
+ const previewPart = preview
216
+ ? ` ${fg(theme.chatList.previewFg, `· ${escapeBlessed(preview)}`)}`
217
+ : "";
218
+ const titlePart = d.pinned
219
+ ? fg(theme.chatList.pinnedFg, `{bold}${escapeBlessed(title)}{/bold}`)
220
+ : `{bold}${escapeBlessed(title)}{/bold}`;
221
+
222
+ return `${pinIcon}${typeIcon}${titlePart}${previewPart} ${fg(theme.chatList.timeFg, timeStr)}${unreadBadge}`;
223
+ }
224
+
225
+ /**
226
+ * Обновляет отображаемый список диалогов.
227
+ * @param {Array<object>} dialogs
228
+ */
229
+ function setDialogs(dialogs) {
230
+ currentDialogs = dialogs;
231
+ // Элементы списка живут внутри list и ещё на колонку уже из-за скроллбара
232
+ // -1 колонка скроллбара, -1 запас: при подсчёте переноса blessed
233
+ // прибавляет к ширине часть символов разметки
234
+ const width = Math.max(10, list.width - 2);
235
+ const items = dialogs.map((d) => formatDialogItem(d, width));
236
+ list.setItems(items);
237
+ screen.render();
238
+ }
239
+
240
+ // Обработка выбора диалога
241
+ list.on("select", (item, index) => {
242
+ if (currentDialogs[index]) {
243
+ onSelectDialog?.(currentDialogs[index]);
244
+ }
245
+ });
246
+
247
+ // Горячие клавиши для переключения вкладок внутри списка
248
+ list.key(["1", "2", "3", "4", "5", "6"], (ch) => {
249
+ const idx = parseInt(ch, 10) - 1;
250
+ if (TAB_KEYS[idx]) {
251
+ currentTab = TAB_KEYS[idx];
252
+ renderTabs();
253
+ onTabChange?.(currentTab);
254
+ }
255
+ });
256
+
257
+ // Быстрый вход в режим поиска по нажатию "/"
258
+ list.key(["/"], () => {
259
+ searchBox.setValue("");
260
+ searchBox.focus();
261
+ screen.render();
262
+ });
263
+
264
+ // Обработка ввода в поле поиска
265
+ searchBox.on("submit", (value) => {
266
+ onSearchChange?.(value === SEARCH_PLACEHOLDER ? "" : value);
267
+ list.focus();
268
+ });
269
+
270
+ searchBox.on("cancel", () => {
271
+ searchBox.setValue(SEARCH_PLACEHOLDER);
272
+ onSearchChange?.("");
273
+ list.focus();
274
+ });
275
+
276
+ renderTabs();
277
+
278
+ return {
279
+ container,
280
+ list,
281
+ searchBox,
282
+ setDialogs,
283
+ setTab: (tab) => {
284
+ currentTab = tab;
285
+ renderTabs();
286
+ },
287
+ focus: () => list.focus(),
288
+ /** Завершает режим ввода в строке поиска (см. inputBox.release). */
289
+ release: () => {
290
+ if (searchBox._reading && typeof searchBox._done === "function") {
291
+ searchBox._done("stop");
292
+ }
293
+ },
294
+ };
295
+ }
@@ -0,0 +1,178 @@
1
+ import blessed from "neo-blessed";
2
+ import { formatMessageTime, formatDateDivider } from "../../utils/time.js";
3
+ import { formatMessageText, escapeBlessed } from "../../telegram/formatter.js";
4
+ import { fg } from "../theme.js";
5
+
6
+ /**
7
+ * Создаёт компонент просмотра сообщений чата (правая центральная панель).
8
+ * @param {blessed.Widgets.Screen} screen
9
+ * @param {object} theme
10
+ * @param {object} callbacks
11
+ * @param {() => void} [callbacks.onLoadMoreHistory]
12
+ * @param {(msg: object) => void} [callbacks.onActionMenu]
13
+ */
14
+ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu } = {}) {
15
+ const container = blessed.box({
16
+ parent: screen,
17
+ top: 4,
18
+ left: "35%",
19
+ right: 0,
20
+ bottom: 6,
21
+ border: {
22
+ type: "line",
23
+ },
24
+ style: {
25
+ bg: theme.chatView.bg,
26
+ fg: theme.chatView.fg,
27
+ border: {
28
+ fg: theme.borders.fg,
29
+ },
30
+ },
31
+ });
32
+
33
+ const scrollBox = blessed.box({
34
+ parent: container,
35
+ top: 0,
36
+ left: 0,
37
+ right: 0,
38
+ bottom: 0,
39
+ tags: true,
40
+ scrollable: true,
41
+ alwaysScroll: true,
42
+ mouse: true,
43
+ keys: true,
44
+ vi: true,
45
+ scrollbar: {
46
+ ch: "│",
47
+ style: {
48
+ bg: theme.scrollbar.bg,
49
+ fg: theme.scrollbar.fg,
50
+ },
51
+ },
52
+ style: {
53
+ bg: theme.chatView.bg,
54
+ fg: theme.chatView.fg,
55
+ },
56
+ });
57
+
58
+ let currentMessages = [];
59
+
60
+ /**
61
+ * Форматирует список сообщений в единую ленту текста с разметкой Blessed.
62
+ * @param {Array<object>} messages
63
+ * @returns {string}
64
+ */
65
+ function renderMessages(messages) {
66
+ if (!messages || messages.length === 0) {
67
+ return `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`;
68
+ }
69
+
70
+ let output = "";
71
+ let lastDateString = "";
72
+
73
+ for (const msg of messages) {
74
+ // Разделитель дат
75
+ const dateStr = formatDateDivider(msg.date);
76
+ if (dateStr && dateStr !== lastDateString) {
77
+ output += `\n ${fg(theme.chatView.dateDivider, `─────── ${escapeBlessed(dateStr)} ───────`)}\n\n`;
78
+ lastDateString = dateStr;
79
+ }
80
+
81
+ const time = formatMessageTime(msg.date);
82
+ const timeTag = fg(theme.chatView.time, `[${time}]`);
83
+
84
+ // Отправитель
85
+ let authorTag = "";
86
+ if (msg.out) {
87
+ const readCheck = fg(theme.chatView.outgoingName, "✓✓");
88
+ authorTag = `${fg(theme.chatView.outgoingName, "{bold}Вы{/bold}")} ${timeTag} ${readCheck}`;
89
+ } else {
90
+ const name = escapeBlessed(msg.senderName || "Собеседник");
91
+ authorTag = `${fg(theme.chatView.incomingName, `{bold}${name}{/bold}`)} ${timeTag}`;
92
+ }
93
+
94
+ // Блок ответа (Reply)
95
+ let replyBlock = "";
96
+ if (msg.replyToMsgId) {
97
+ replyBlock = ` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}\n`;
98
+ }
99
+
100
+ // Текст сообщения и entities
101
+ let bodyText = formatMessageText(msg.text, msg.entities);
102
+ if (msg.mediaDescription) {
103
+ bodyText = bodyText ? `${msg.mediaDescription}\n ${bodyText}` : msg.mediaDescription;
104
+ }
105
+
106
+ // Отступ строк текста сообщения
107
+ const indentedBody = bodyText
108
+ .split("\n")
109
+ .map((line) => ` ${line}`)
110
+ .join("\n");
111
+
112
+ // Реакции
113
+ let reactionsLine = "";
114
+ if (msg.reactions && msg.reactions.length > 0) {
115
+ const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
116
+ reactionsLine = `\n ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`;
117
+ }
118
+
119
+ // Метка редактирования
120
+ let editedTag = "";
121
+ if (msg.editDate) {
122
+ editedTag = ` ${fg(theme.chatView.time, "(изменено)")}`;
123
+ }
124
+
125
+ output += ` ${authorTag}${editedTag}\n${replyBlock}${indentedBody}${reactionsLine}\n\n`;
126
+ }
127
+
128
+ return output;
129
+ }
130
+
131
+ /**
132
+ * Устанавливает сообщения в ленту.
133
+ * @param {Array<object>} messages
134
+ * @param {boolean} [autoScrollToBottom=true]
135
+ */
136
+ function setMessages(messages, autoScrollToBottom = true) {
137
+ currentMessages = messages;
138
+ scrollBox.setContent(renderMessages(messages));
139
+ if (autoScrollToBottom) {
140
+ scrollBox.setScrollPerc(100);
141
+ }
142
+ screen.render();
143
+ }
144
+
145
+ // Обработка прокрутки вверх для подгрузки истории
146
+ scrollBox.key(["pageup", "C-u"], () => {
147
+ scrollBox.scroll(-10);
148
+ if (scrollBox.getScroll() <= 0) {
149
+ onLoadMoreHistory?.();
150
+ }
151
+ screen.render();
152
+ });
153
+
154
+ scrollBox.key(["pagedown", "C-d"], () => {
155
+ scrollBox.scroll(10);
156
+ screen.render();
157
+ });
158
+
159
+ // Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
160
+ // висит на Ctrl+A — иначе оно недостижимо.
161
+ scrollBox.key(["C-a"], () => {
162
+ if (currentMessages.length > 0) {
163
+ const lastMsg = currentMessages[currentMessages.length - 1];
164
+ onActionMenu?.(lastMsg);
165
+ }
166
+ });
167
+
168
+ return {
169
+ container,
170
+ scrollBox,
171
+ setMessages,
172
+ scrollToBottom: () => {
173
+ scrollBox.setScrollPerc(100);
174
+ screen.render();
175
+ },
176
+ focus: () => scrollBox.focus(),
177
+ };
178
+ }
@@ -0,0 +1,82 @@
1
+ import blessed from "neo-blessed";
2
+ import { escapeBlessed } from "../../telegram/formatter.js";
3
+ import { fg } from "../theme.js";
4
+
5
+ /**
6
+ * Верхняя шапка приложения с информацией о пользователе, активном чате и статусе соединения.
7
+ * @param {blessed.Widgets.Screen} screen
8
+ * @param {object} theme
9
+ * @returns {blessed.Widgets.BoxElement & { updateInfo: (data: object) => void }}
10
+ */
11
+ export function createHeader(screen, theme) {
12
+ const headerBox = blessed.box({
13
+ parent: screen,
14
+ top: 0,
15
+ left: 0,
16
+ width: "100%",
17
+ // 4 = рамка (2) + две строки контента (профиль + активный чат)
18
+ height: 4,
19
+ tags: true,
20
+ border: {
21
+ type: "line",
22
+ },
23
+ style: {
24
+ bg: theme.header.bg,
25
+ fg: theme.header.fg,
26
+ border: {
27
+ fg: theme.borders.fg,
28
+ },
29
+ },
30
+ });
31
+
32
+ /**
33
+ * Обновляет содержимое шапки.
34
+ * @param {object} data
35
+ * @param {object|null} [data.me]
36
+ * @param {string} [data.status]
37
+ * @param {object|null} [data.activeChat]
38
+ * @param {string|null} [data.typingUser]
39
+ */
40
+ headerBox.updateInfo = function ({ me, status = "connected", activeChat, typingUser }) {
41
+ let statusBadge = fg(theme.status.online, "● В сети");
42
+ if (status === "connecting") {
43
+ statusBadge = fg(theme.status.connecting, "◌ Подключение...");
44
+ } else if (status === "disconnected") {
45
+ statusBadge = fg(theme.status.offline, "○ Не в сети");
46
+ }
47
+
48
+ const userTitle = me
49
+ ? `${me.firstName || ""} ${me.lastName || ""} ${me.username ? `(@${me.username})` : ""}`.trim()
50
+ : "Авторизация...";
51
+
52
+ let chatTitle = fg(theme.muted, "Выберите чат из списка слева");
53
+ if (activeChat) {
54
+ const icon =
55
+ activeChat.type === "channel" ? "📢" :
56
+ activeChat.type === "supergroup" || activeChat.type === "group" ? "👥" :
57
+ activeChat.type === "bot" ? "🤖" :
58
+ activeChat.type === "saved" ? "⭐" : "👤";
59
+
60
+ let details = "";
61
+ if (activeChat.username) details += ` (@${activeChat.username})`;
62
+ if (activeChat.entity?.participantsCount) details += ` [${activeChat.entity.participantsCount} уч.]`;
63
+
64
+ chatTitle = `{bold}${icon} ${escapeBlessed(activeChat.title)}${escapeBlessed(details)}{/bold}`;
65
+ }
66
+
67
+ let typingNotice = "";
68
+ if (typingUser) {
69
+ typingNotice = ` ${fg(theme.warning, `✍️ ${escapeBlessed(typingUser)} печатает...`)}`;
70
+ }
71
+
72
+ const divider = fg(theme.dim, "│");
73
+ const leftText = `{bold}🚀 TuiGram{/bold} ${divider} ${escapeBlessed(userTitle)} ${divider} ${statusBadge}`;
74
+
75
+ headerBox.setContent(
76
+ ` ${leftText}\n ${chatTitle}${typingNotice}`
77
+ );
78
+ screen.render();
79
+ };
80
+
81
+ return headerBox;
82
+ }