@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.
- package/.env.example +7 -0
- package/CHANGELOG.md +27 -1
- package/CHANGELOG.ru.md +27 -1
- package/README.md +43 -0
- package/README.ru.md +43 -0
- package/package.json +3 -1
- package/src/config.js +3 -0
- package/src/state.js +4 -4
- package/src/telegram/messages.js +58 -0
- package/src/ui/app.js +169 -5
- package/src/ui/components/chatList.js +62 -0
- package/src/ui/components/chatView.js +116 -14
- package/src/ui/components/header.js +24 -1
- package/src/ui/components/inputBox.js +40 -1
- package/src/ui/components/modals/actionModal.js +31 -0
- package/src/ui/components/modals/chatInfoModal.js +19 -0
- package/src/ui/components/modals/confirmModal.js +18 -0
- package/src/ui/components/modals/fileModal.js +33 -1
- package/src/ui/components/modals/filePickerModal.js +19 -0
- package/src/ui/components/modals/helpModal.js +24 -0
- package/src/ui/components/statusBar.js +53 -1
- package/src/ui/screen.js +52 -3
- package/src/utils/image.js +343 -0
- package/src/utils/mouse.js +236 -0
|
@@ -4,6 +4,7 @@ import { inspectLocalFile } from "../../../utils/storage.js";
|
|
|
4
4
|
import { formatFileSize } from "../../../utils/time.js";
|
|
5
5
|
import { createFilePickerModal } from "./filePickerModal.js";
|
|
6
6
|
import { fg } from "../../theme.js";
|
|
7
|
+
import { isInsideBox } from "../../../utils/mouse.js";
|
|
7
8
|
|
|
8
9
|
/** Разделитель нескольких путей в поле ввода. */
|
|
9
10
|
const PATH_SEPARATOR = "|";
|
|
@@ -27,6 +28,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
27
28
|
height: 14,
|
|
28
29
|
hidden: true,
|
|
29
30
|
tags: true,
|
|
31
|
+
mouse: true,
|
|
30
32
|
border: {
|
|
31
33
|
type: "line",
|
|
32
34
|
},
|
|
@@ -51,13 +53,15 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
51
53
|
style: { bg: theme.modal.bg, fg: theme.modal.fg },
|
|
52
54
|
});
|
|
53
55
|
|
|
54
|
-
blessed.box({
|
|
56
|
+
const browseBar = blessed.box({
|
|
55
57
|
parent: modal,
|
|
56
58
|
top: 2,
|
|
57
59
|
left: 2,
|
|
58
60
|
right: 2,
|
|
59
61
|
height: 1,
|
|
60
62
|
tags: true,
|
|
63
|
+
mouse: true,
|
|
64
|
+
clickable: true,
|
|
61
65
|
content: `Путь к файлу ${fg(theme.modal.hintFg, `(несколько — через ${PATH_SEPARATOR})`)}{|}${fg(theme.accent, "[Ctrl+F] Обзор")} `,
|
|
62
66
|
style: { bg: theme.modal.bg, fg: theme.modal.fg },
|
|
63
67
|
});
|
|
@@ -69,6 +73,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
69
73
|
right: 2,
|
|
70
74
|
height: 1,
|
|
71
75
|
inputOnFocus: true,
|
|
76
|
+
mouse: true,
|
|
72
77
|
style: {
|
|
73
78
|
bg: theme.modal.inputBg,
|
|
74
79
|
fg: theme.modal.inputFg,
|
|
@@ -93,6 +98,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
93
98
|
right: 2,
|
|
94
99
|
height: 1,
|
|
95
100
|
inputOnFocus: true,
|
|
101
|
+
mouse: true,
|
|
96
102
|
style: {
|
|
97
103
|
bg: theme.modal.inputBg,
|
|
98
104
|
fg: theme.modal.inputFg,
|
|
@@ -338,10 +344,36 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
|
|
|
338
344
|
});
|
|
339
345
|
captionInput.on("submit", submit);
|
|
340
346
|
sendBtn.on("press", submit);
|
|
347
|
+
sendBtn.on("click", submit);
|
|
341
348
|
cancelBtn.on("press", hide);
|
|
349
|
+
cancelBtn.on("click", hide);
|
|
342
350
|
asDocumentCheck.on("check", () => validate({ quiet: true }));
|
|
343
351
|
asDocumentCheck.on("uncheck", () => validate({ quiet: true }));
|
|
344
352
|
|
|
353
|
+
browseBar.on("click", openPicker);
|
|
354
|
+
pathInput.on("click", () => {
|
|
355
|
+
pathInput.focus();
|
|
356
|
+
screen.render();
|
|
357
|
+
});
|
|
358
|
+
captionInput.on("click", () => {
|
|
359
|
+
captionInput.focus();
|
|
360
|
+
screen.render();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Закрытие при клике мышью мимо модального окна
|
|
364
|
+
screen.on("click", (data) => {
|
|
365
|
+
if (!modal.visible || picker.modal.visible) return;
|
|
366
|
+
const inside = isInsideBox(data.x, data.y, {
|
|
367
|
+
left: modal.aleft,
|
|
368
|
+
top: modal.atop,
|
|
369
|
+
width: modal.width,
|
|
370
|
+
height: modal.height,
|
|
371
|
+
});
|
|
372
|
+
if (!inside) {
|
|
373
|
+
hide();
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
345
377
|
return {
|
|
346
378
|
modal,
|
|
347
379
|
pathInput,
|
|
@@ -4,6 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { formatFileSize } from "../../../utils/time.js";
|
|
6
6
|
import { fg } from "../../theme.js";
|
|
7
|
+
import { isInsideBox } from "../../../utils/mouse.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Модальное окно выбора локального файла (обёртка над blessed.filemanager).
|
|
@@ -23,6 +24,7 @@ export function createFilePickerModal(screen, theme) {
|
|
|
23
24
|
height: "60%",
|
|
24
25
|
hidden: true,
|
|
25
26
|
tags: true,
|
|
27
|
+
mouse: true,
|
|
26
28
|
border: {
|
|
27
29
|
type: "line",
|
|
28
30
|
},
|
|
@@ -78,6 +80,8 @@ export function createFilePickerModal(screen, theme) {
|
|
|
78
80
|
right: 1,
|
|
79
81
|
height: 2,
|
|
80
82
|
tags: true,
|
|
83
|
+
mouse: true,
|
|
84
|
+
clickable: true,
|
|
81
85
|
style: { bg: theme.modal.bg, fg: theme.modal.fg },
|
|
82
86
|
});
|
|
83
87
|
|
|
@@ -187,6 +191,21 @@ export function createFilePickerModal(screen, theme) {
|
|
|
187
191
|
// filemanager сам отдаёт "cancel" по Escape (list.js), но у него нет hide()
|
|
188
192
|
manager.key(["escape", "q"], hide);
|
|
189
193
|
manager.on("cancel", hide);
|
|
194
|
+
footer.on("click", hide);
|
|
195
|
+
|
|
196
|
+
// Закрытие при клике мышью мимо модального окна
|
|
197
|
+
screen.on("click", (data) => {
|
|
198
|
+
if (!modal.visible) return;
|
|
199
|
+
const inside = isInsideBox(data.x, data.y, {
|
|
200
|
+
left: modal.aleft,
|
|
201
|
+
top: modal.atop,
|
|
202
|
+
width: modal.width,
|
|
203
|
+
height: modal.height,
|
|
204
|
+
});
|
|
205
|
+
if (!inside) {
|
|
206
|
+
hide();
|
|
207
|
+
}
|
|
208
|
+
});
|
|
190
209
|
|
|
191
210
|
return {
|
|
192
211
|
modal,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
|
+
import { isInsideBox } from "../../../utils/mouse.js";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Создаёт модальное окно справки по всем горячим клавишам и возможностям.
|
|
@@ -15,6 +16,8 @@ export function createHelpModal(screen, theme) {
|
|
|
15
16
|
height: "80%",
|
|
16
17
|
hidden: true,
|
|
17
18
|
tags: true,
|
|
19
|
+
mouse: true,
|
|
20
|
+
scrollable: true,
|
|
18
21
|
border: {
|
|
19
22
|
type: "line",
|
|
20
23
|
},
|
|
@@ -46,6 +49,12 @@ export function createHelpModal(screen, theme) {
|
|
|
46
49
|
${K_CYAN}[PageUp] / [Ctrl+U]${K_END} Прокрутка сообщений вверх / Подгрузка старой истории
|
|
47
50
|
${K_CYAN}[PageDown] / [Ctrl+D]${K_END}Прокрутка сообщений вниз
|
|
48
51
|
|
|
52
|
+
{bold}Управление мышью:{/bold}
|
|
53
|
+
${K_CYAN}Клик по диалогу${K_END} Мгновенно открыть чат
|
|
54
|
+
${K_CYAN}Колесо мыши${K_END} Прокрутка списка чатов и сообщений (вверх — подгрузка истории)
|
|
55
|
+
${K_CYAN}Клик по сообщению${K_END} Открыть меню действий над сообщением
|
|
56
|
+
${K_CYAN}Клик по вкладкам/кнопкам${K_END} Переключение фильтров и вызов действий
|
|
57
|
+
|
|
49
58
|
{bold}Вкладки фильтрации диалогов (нажмите цифру в списке чатов):{/bold}
|
|
50
59
|
${K_YELLOW}[1]${K_END} Все чаты ${K_YELLOW}[2]${K_END} Личные (ЛС) ${K_YELLOW}[3]${K_END} Группы
|
|
51
60
|
${K_YELLOW}[4]${K_END} Каналы ${K_YELLOW}[5]${K_END} Боты ${K_YELLOW}[6]${K_END} Непрочитанные
|
|
@@ -120,9 +129,24 @@ export function createHelpModal(screen, theme) {
|
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
closeBtn.on("press", hide);
|
|
132
|
+
closeBtn.on("click", hide);
|
|
123
133
|
// Клавиши вешаем на кнопку: blessed отдаёт события только сфокусированному элементу.
|
|
124
134
|
closeBtn.key(["escape", "q", "f1"], hide);
|
|
125
135
|
|
|
136
|
+
// Закрытие при клике мышью мимо модального окна
|
|
137
|
+
screen.on("click", (data) => {
|
|
138
|
+
if (!modal.visible) return;
|
|
139
|
+
const inside = isInsideBox(data.x, data.y, {
|
|
140
|
+
left: modal.aleft,
|
|
141
|
+
top: modal.atop,
|
|
142
|
+
width: modal.width,
|
|
143
|
+
height: modal.height,
|
|
144
|
+
});
|
|
145
|
+
if (!inside) {
|
|
146
|
+
hide();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
126
150
|
return {
|
|
127
151
|
modal,
|
|
128
152
|
show,
|
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
import blessed from "neo-blessed";
|
|
2
2
|
import { fg } from "../theme.js";
|
|
3
3
|
|
|
4
|
+
import { getStatusBarActionAt } from "../../utils/mouse.js";
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Создаёт нижнюю строку состояния (Status Bar) с подсказками и временными тостами.
|
|
6
8
|
* @param {blessed.Widgets.Screen} screen
|
|
7
9
|
* @param {object} theme
|
|
10
|
+
* @param {object} [callbacks]
|
|
11
|
+
* @param {() => void} [callbacks.onFocusNext]
|
|
12
|
+
* @param {() => void} [callbacks.onSelectOrSubmit]
|
|
13
|
+
* @param {() => void} [callbacks.onFilterTabs]
|
|
14
|
+
* @param {() => void} [callbacks.onSearch]
|
|
15
|
+
* @param {() => void} [callbacks.onHelp]
|
|
16
|
+
* @param {() => void} [callbacks.onAction]
|
|
17
|
+
* @param {() => void} [callbacks.onChatInfo]
|
|
18
|
+
* @param {() => void} [callbacks.onQuit]
|
|
8
19
|
* @returns {blessed.Widgets.BoxElement & { showMessage: (text: string, type?: string, duration?: number) => void }}
|
|
9
20
|
*/
|
|
10
|
-
export function createStatusBar(screen, theme
|
|
21
|
+
export function createStatusBar(screen, theme, {
|
|
22
|
+
onFocusNext,
|
|
23
|
+
onSelectOrSubmit,
|
|
24
|
+
onFilterTabs,
|
|
25
|
+
onSearch,
|
|
26
|
+
onHelp,
|
|
27
|
+
onAction,
|
|
28
|
+
onChatInfo,
|
|
29
|
+
onQuit,
|
|
30
|
+
} = {}) {
|
|
11
31
|
const statusBar = blessed.box({
|
|
12
32
|
parent: screen,
|
|
13
33
|
bottom: 0,
|
|
@@ -15,12 +35,44 @@ export function createStatusBar(screen, theme) {
|
|
|
15
35
|
width: "100%",
|
|
16
36
|
height: 1,
|
|
17
37
|
tags: true,
|
|
38
|
+
mouse: true,
|
|
18
39
|
style: {
|
|
19
40
|
bg: theme.status.bg,
|
|
20
41
|
fg: theme.status.fg,
|
|
21
42
|
},
|
|
22
43
|
});
|
|
23
44
|
|
|
45
|
+
statusBar.on("click", (data) => {
|
|
46
|
+
const relX = data.x - (statusBar.aleft || 0);
|
|
47
|
+
const action = getStatusBarActionAt(relX, statusBar.width || 120);
|
|
48
|
+
switch (action) {
|
|
49
|
+
case "focus":
|
|
50
|
+
onFocusNext?.();
|
|
51
|
+
break;
|
|
52
|
+
case "select":
|
|
53
|
+
onSelectOrSubmit?.();
|
|
54
|
+
break;
|
|
55
|
+
case "tabs":
|
|
56
|
+
onFilterTabs?.();
|
|
57
|
+
break;
|
|
58
|
+
case "search":
|
|
59
|
+
onSearch?.();
|
|
60
|
+
break;
|
|
61
|
+
case "help":
|
|
62
|
+
onHelp?.();
|
|
63
|
+
break;
|
|
64
|
+
case "actions":
|
|
65
|
+
onAction?.();
|
|
66
|
+
break;
|
|
67
|
+
case "info":
|
|
68
|
+
onChatInfo?.();
|
|
69
|
+
break;
|
|
70
|
+
case "quit":
|
|
71
|
+
onQuit?.();
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
24
76
|
let currentTimeout = null;
|
|
25
77
|
|
|
26
78
|
const key = (label) => `{bold}${fg(theme.accent, label)}{/bold}`;
|
package/src/ui/screen.js
CHANGED
|
@@ -27,11 +27,15 @@ export const GLOBAL_KEYS = [
|
|
|
27
27
|
* @returns {blessed.Widgets.Screen}
|
|
28
28
|
*/
|
|
29
29
|
export function createScreen({ theme, onExit } = {}) {
|
|
30
|
+
// Форсируем поддержку SGR и CellMotion режимов в neo-blessed для современных терминалов
|
|
31
|
+
process.env.BLESSED_FORCE_MODES = "SGRMOUSE=1,CELLMOTION=1,VT200MOUSE=1,UTFMOUSE=0,ALLMOTION=0,URXVTMOUSE=1";
|
|
32
|
+
|
|
30
33
|
const screen = blessed.screen({
|
|
31
34
|
smartCSR: true,
|
|
32
35
|
title: "TuiGram - Telegram Terminal Client",
|
|
33
36
|
fullUnicode: true,
|
|
34
37
|
dockBorders: true,
|
|
38
|
+
sendFocus: true,
|
|
35
39
|
cursor: {
|
|
36
40
|
synthetic: true,
|
|
37
41
|
blink: true,
|
|
@@ -40,21 +44,66 @@ export function createScreen({ theme, onExit } = {}) {
|
|
|
40
44
|
style: {
|
|
41
45
|
bg: theme?.bg ?? "black",
|
|
42
46
|
fg: theme?.fg ?? "white",
|
|
43
|
-
}
|
|
47
|
+
},
|
|
44
48
|
});
|
|
45
49
|
|
|
46
50
|
screen.ignoreLocked = [...GLOBAL_KEYS];
|
|
47
51
|
|
|
52
|
+
// Настраиваем режимы мыши в program:
|
|
53
|
+
// SGR (1006) — стандарт для macOS Terminal, iTerm2, Alacritty, Kitty, Windows Terminal
|
|
54
|
+
// CellMotion (1002) — клики, зажатия и скролл
|
|
55
|
+
// VT200 (1000) — базовая поддержка кнопок
|
|
56
|
+
// urxvt (1015) — fallback
|
|
57
|
+
screen.program.setMouse({
|
|
58
|
+
vt200Mouse: true,
|
|
59
|
+
cellMotion: true,
|
|
60
|
+
allMotion: false,
|
|
61
|
+
sgrMouse: true,
|
|
62
|
+
urxvtMouse: true,
|
|
63
|
+
utfMouse: false,
|
|
64
|
+
}, true);
|
|
65
|
+
|
|
66
|
+
const enableMouseSeq = "\x1b[?1003l\x1b[?1005l\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h";
|
|
67
|
+
const disableMouseSeq = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l";
|
|
68
|
+
|
|
69
|
+
function sendEnableMouse() {
|
|
70
|
+
try {
|
|
71
|
+
if (screen.program && screen.program.output && typeof screen.program.output.write === "function") {
|
|
72
|
+
screen.program.output.write(enableMouseSeq);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Игнорируем в headless/тестах
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
sendEnableMouse();
|
|
80
|
+
screen.enableMouse();
|
|
81
|
+
|
|
82
|
+
// Отправляем escape-последовательности повторно при перерендере/ресайзе экрана
|
|
83
|
+
screen.on("render", sendEnableMouse);
|
|
84
|
+
screen.on("resize", sendEnableMouse);
|
|
85
|
+
|
|
86
|
+
// Пробрасываем событие click на уровне экрана при mouseup
|
|
87
|
+
screen.on("mouseup", (data) => {
|
|
88
|
+
screen.emit("click", data);
|
|
89
|
+
});
|
|
90
|
+
|
|
48
91
|
// Обработка закрытия терминала или аварийного прерывания
|
|
49
|
-
|
|
92
|
+
function cleanExit() {
|
|
50
93
|
try {
|
|
94
|
+
if (screen.program && screen.program.output && typeof screen.program.output.write === "function") {
|
|
95
|
+
screen.program.output.write(disableMouseSeq);
|
|
96
|
+
}
|
|
97
|
+
screen.program.disableMouse();
|
|
51
98
|
onExit?.();
|
|
52
99
|
} catch {
|
|
53
100
|
// Выходим в любом случае
|
|
54
101
|
}
|
|
55
102
|
screen.destroy();
|
|
56
103
|
process.exit(0);
|
|
57
|
-
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
screen.key(["C-c"], cleanExit);
|
|
58
107
|
|
|
59
108
|
return screen;
|
|
60
109
|
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Модуль обработки и рендеринга изображений в терминале (Unicode Half-Block).
|
|
3
|
+
* Преобразует растровые изображения (JPEG, PNG, PhotoStrippedSize) в псевдографику
|
|
4
|
+
* высокого разрешения с разметкой blessed и 24-битными hex-цветами.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import jpegJs from "jpeg-js";
|
|
8
|
+
import { PNG } from "pngjs";
|
|
9
|
+
|
|
10
|
+
/** Заголовок стандартного JPEG для распаковки Telegram PhotoStrippedSize. */
|
|
11
|
+
const JPEG_HEADER = Buffer.from([
|
|
12
|
+
0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01,
|
|
13
|
+
0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0xff, 0xdb, 0x00, 0x43,
|
|
14
|
+
0x00, 0x28, 0x1c, 0x1e, 0x23, 0x1e, 0x19, 0x28, 0x23, 0x21, 0x23, 0x2d,
|
|
15
|
+
0x2b, 0x28, 0x30, 0x3c, 0x64, 0x41, 0x3c, 0x37, 0x37, 0x3c, 0x7b, 0x58,
|
|
16
|
+
0x5d, 0x49, 0x64, 0x91, 0x80, 0x99, 0x96, 0x8f, 0x80, 0x8c, 0x8a, 0xa0,
|
|
17
|
+
0xb4, 0xe6, 0xc3, 0xa0, 0xaa, 0xda, 0xad, 0x8a, 0x8c, 0xc8, 0xff, 0xcb,
|
|
18
|
+
0xda, 0xee, 0xf5, 0xff, 0xff, 0xff, 0x9b, 0xc1, 0xff, 0xff, 0xf7, 0xfa,
|
|
19
|
+
0xff, 0xe6, 0xfd, 0xff, 0xf8, 0xff, 0xdb, 0x00, 0x43, 0x01, 0x2b, 0x2d,
|
|
20
|
+
0x2d, 0x3c, 0x35, 0x3c, 0x75, 0x41, 0x41, 0x75, 0xf8, 0xa5, 0x8c, 0xa5,
|
|
21
|
+
0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
|
|
22
|
+
0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
|
|
23
|
+
0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8,
|
|
24
|
+
0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xf8, 0xff, 0xc0, 0x00,
|
|
25
|
+
0x11, 0x08, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x22, 0x00, 0x02, 0x11,
|
|
26
|
+
0x01, 0x03, 0x11, 0x01, 0xff, 0xc4, 0x00, 0x1f, 0x00, 0x00, 0x01, 0x05,
|
|
27
|
+
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
28
|
+
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
|
|
29
|
+
0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x10, 0x00, 0x02, 0x01, 0x03, 0x03, 0x02,
|
|
30
|
+
0x04, 0x03, 0x05, 0x05, 0x04, 0x04, 0x00, 0x00, 0x01, 0x7d, 0x01, 0x02,
|
|
31
|
+
0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51,
|
|
32
|
+
0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xa1, 0x08, 0x23, 0x42,
|
|
33
|
+
0xb1, 0xc1, 0x15, 0x52, 0xd1, 0xf0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09,
|
|
34
|
+
0x0a, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
|
|
35
|
+
0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46, 0x47,
|
|
36
|
+
0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x63,
|
|
37
|
+
0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76, 0x77,
|
|
38
|
+
0x78, 0x79, 0x7a, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x92,
|
|
39
|
+
0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3, 0xa4, 0xa5,
|
|
40
|
+
0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8,
|
|
41
|
+
0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xd2,
|
|
42
|
+
0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe1, 0xe2, 0xe3, 0xe4,
|
|
43
|
+
0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6,
|
|
44
|
+
0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xc4, 0x00, 0x1f, 0x01, 0x00, 0x03, 0x01,
|
|
45
|
+
0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00,
|
|
46
|
+
0x00, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
|
|
47
|
+
0x0b, 0xff, 0xc4, 0x00, 0xb5, 0x11, 0x00, 0x02, 0x01, 0x02, 0x04, 0x04,
|
|
48
|
+
0x03, 0x04, 0x07, 0x05, 0x04, 0x04, 0x00, 0x01, 0x02, 0x77, 0x00, 0x01,
|
|
49
|
+
0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07,
|
|
50
|
+
0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, 0xa1, 0xb1,
|
|
51
|
+
0xc1, 0x09, 0x23, 0x33, 0x52, 0xf0, 0x15, 0x62, 0x72, 0xd1, 0x0a, 0x16,
|
|
52
|
+
0x24, 0x34, 0xe1, 0x25, 0xf1, 0x17, 0x18, 0x19, 0x1a, 0x26, 0x27, 0x28,
|
|
53
|
+
0x29, 0x2a, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x43, 0x44, 0x45, 0x46,
|
|
54
|
+
0x47, 0x48, 0x49, 0x4a, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a,
|
|
55
|
+
0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x73, 0x74, 0x75, 0x76,
|
|
56
|
+
0x77, 0x78, 0x79, 0x7a, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89,
|
|
57
|
+
0x8a, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0xa2, 0xa3,
|
|
58
|
+
0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6,
|
|
59
|
+
0xb7, 0xb8, 0xb9, 0xba, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9,
|
|
60
|
+
0xca, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xe2, 0xe3,
|
|
61
|
+
0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6,
|
|
62
|
+
0xf7, 0xf8, 0xf9, 0xfa, 0xff, 0xda, 0x00, 0x0c, 0x03, 0x01, 0x00, 0x02,
|
|
63
|
+
0x11, 0x03, 0x11, 0x00, 0x3f, 0x00
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/** Завершающий маркер JPEG (End Of Image). */
|
|
67
|
+
const JPEG_FOOTER = Buffer.from([0xff, 0xd9]);
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Память кэша для готовых строк псевдографики.
|
|
71
|
+
* @type {Map<string, string>}
|
|
72
|
+
*/
|
|
73
|
+
export const imagePreviewCache = new Map();
|
|
74
|
+
const MAX_CACHE_SIZE = 500;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Распаковывает PhotoStrippedSize Telegram в валидный JPEG буфер.
|
|
78
|
+
* @param {Buffer|Uint8Array} stripped
|
|
79
|
+
* @returns {Buffer}
|
|
80
|
+
*/
|
|
81
|
+
export function strippedPhotoToJpg(stripped) {
|
|
82
|
+
if (!stripped || stripped.length < 3) {
|
|
83
|
+
return Buffer.isBuffer(stripped) ? stripped : Buffer.from(stripped || []);
|
|
84
|
+
}
|
|
85
|
+
const buf = Buffer.isBuffer(stripped) ? stripped : Buffer.from(stripped);
|
|
86
|
+
if (buf[0] !== 1) {
|
|
87
|
+
return buf;
|
|
88
|
+
}
|
|
89
|
+
const header = Buffer.from(JPEG_HEADER);
|
|
90
|
+
header[164] = buf[1];
|
|
91
|
+
header[166] = buf[2];
|
|
92
|
+
return Buffer.concat([header, buf.subarray(3), JPEG_FOOTER]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Декодирует буфер изображения (JPEG или PNG) в сырой RGBA буфер.
|
|
97
|
+
* @param {Buffer|Uint8Array} buffer
|
|
98
|
+
* @param {string} [mimeType]
|
|
99
|
+
* @returns {{ width: number, height: number, data: Uint8Array }}
|
|
100
|
+
*/
|
|
101
|
+
export function decodeImageBuffer(buffer, mimeType = "") {
|
|
102
|
+
if (!buffer || buffer.length === 0) {
|
|
103
|
+
throw new Error("Пустой буфер изображения");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
|
107
|
+
|
|
108
|
+
// Определение формата по magic bytes или mimeType
|
|
109
|
+
const isPng = (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47)
|
|
110
|
+
|| mimeType.includes("png");
|
|
111
|
+
|
|
112
|
+
if (isPng) {
|
|
113
|
+
const png = PNG.sync.read(buf);
|
|
114
|
+
return {
|
|
115
|
+
width: png.width,
|
|
116
|
+
height: png.height,
|
|
117
|
+
data: png.data,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// По умолчанию считаем JPEG
|
|
122
|
+
const decoded = jpegJs.decode(buf, { useTArray: true, formatAsRGBA: true });
|
|
123
|
+
return {
|
|
124
|
+
width: decoded.width,
|
|
125
|
+
height: decoded.height,
|
|
126
|
+
data: decoded.data,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Рассчитывает целевые размеры изображения в терминале с сохранением пропорций.
|
|
132
|
+
* Каждый терминальный ряд Half-Block вмещает 2 вертикальных субпикселя.
|
|
133
|
+
* @param {number} srcW Исходная ширина в пикселях
|
|
134
|
+
* @param {number} srcH Исходная высота в пикселях
|
|
135
|
+
* @param {number} [maxWidth=36] Максимальная ширина в колонках символов
|
|
136
|
+
* @param {number} [maxHeight=14] Максимальная высота в строках терминала
|
|
137
|
+
* @returns {{ dstW: number, dstH: number, rows: number }}
|
|
138
|
+
*/
|
|
139
|
+
export function calculateTargetDimensions(srcW, srcH, maxWidth = 36, maxHeight = 14) {
|
|
140
|
+
const safeW = Math.max(1, srcW || 1);
|
|
141
|
+
const safeH = Math.max(1, srcH || 1);
|
|
142
|
+
const maxPixelH = Math.max(2, maxHeight * 2);
|
|
143
|
+
|
|
144
|
+
const aspect = safeW / safeH;
|
|
145
|
+
const maxAspect = maxWidth / maxPixelH;
|
|
146
|
+
|
|
147
|
+
let dstW = maxWidth;
|
|
148
|
+
let dstPixelH = maxPixelH;
|
|
149
|
+
|
|
150
|
+
if (aspect >= maxAspect) {
|
|
151
|
+
dstW = Math.min(maxWidth, safeW);
|
|
152
|
+
dstPixelH = Math.max(2, Math.round(dstW / aspect));
|
|
153
|
+
} else {
|
|
154
|
+
dstPixelH = Math.min(maxPixelH, safeH * 2);
|
|
155
|
+
dstW = Math.max(1, Math.round(dstPixelH * aspect));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Округляем пиксельную высоту до четного числа (для парных субпикселей ▀)
|
|
159
|
+
const rows = Math.max(1, Math.ceil(dstPixelH / 2));
|
|
160
|
+
dstPixelH = rows * 2;
|
|
161
|
+
|
|
162
|
+
return { dstW, dstH: dstPixelH, rows };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Масштабирует RGBA буфер методом билинейной интерполяции.
|
|
167
|
+
* @param {Uint8Array} src RGBA буфер исходного изображения
|
|
168
|
+
* @param {number} srcW Исходная ширина
|
|
169
|
+
* @param {number} srcH Исходная высота
|
|
170
|
+
* @param {number} dstW Целевая ширина
|
|
171
|
+
* @param {number} dstH Целевая высота
|
|
172
|
+
* @returns {Uint8Array} Результирующий RGBA буфер длины dstW * dstH * 4
|
|
173
|
+
*/
|
|
174
|
+
export function resizeRgba(src, srcW, srcH, dstW, dstH) {
|
|
175
|
+
const dst = new Uint8Array(dstW * dstH * 4);
|
|
176
|
+
if (srcW === dstW && srcH === dstH) {
|
|
177
|
+
dst.set(src);
|
|
178
|
+
return dst;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const xRatio = srcW / dstW;
|
|
182
|
+
const yRatio = srcH / dstH;
|
|
183
|
+
|
|
184
|
+
for (let dy = 0; dy < dstH; dy++) {
|
|
185
|
+
const srcY = (dy + 0.5) * yRatio - 0.5;
|
|
186
|
+
const y0 = Math.max(0, Math.min(srcH - 1, Math.floor(srcY)));
|
|
187
|
+
const y1 = Math.max(0, Math.min(srcH - 1, y0 + 1));
|
|
188
|
+
const yWeight = Math.max(0, Math.min(1, srcY - y0));
|
|
189
|
+
|
|
190
|
+
const y0Offset = y0 * srcW * 4;
|
|
191
|
+
const y1Offset = y1 * srcW * 4;
|
|
192
|
+
const dstRowOffset = dy * dstW * 4;
|
|
193
|
+
|
|
194
|
+
for (let dx = 0; dx < dstW; dx++) {
|
|
195
|
+
const srcX = (dx + 0.5) * xRatio - 0.5;
|
|
196
|
+
const x0 = Math.max(0, Math.min(srcW - 1, Math.floor(srcX)));
|
|
197
|
+
const x1 = Math.max(0, Math.min(srcW - 1, x0 + 1));
|
|
198
|
+
const xWeight = Math.max(0, Math.min(1, srcX - x0));
|
|
199
|
+
|
|
200
|
+
const p00 = y0Offset + x0 * 4;
|
|
201
|
+
const p10 = y0Offset + x1 * 4;
|
|
202
|
+
const p01 = y1Offset + x0 * 4;
|
|
203
|
+
const p11 = y1Offset + x1 * 4;
|
|
204
|
+
|
|
205
|
+
const dstOffset = dstRowOffset + dx * 4;
|
|
206
|
+
|
|
207
|
+
// Интерполяция 4 каналов: R, G, B, A
|
|
208
|
+
for (let c = 0; c < 4; c++) {
|
|
209
|
+
const top = (1 - xWeight) * src[p00 + c] + xWeight * src[p10 + c];
|
|
210
|
+
const bot = (1 - xWeight) * src[p01 + c] + xWeight * src[p11 + c];
|
|
211
|
+
dst[dstOffset + c] = Math.round((1 - yWeight) * top + yWeight * bot);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return dst;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Преобразует компоненты цвета R, G, B в hex-строку вида "#rrggbb".
|
|
221
|
+
* @param {number} r
|
|
222
|
+
* @param {number} g
|
|
223
|
+
* @param {number} b
|
|
224
|
+
* @returns {string}
|
|
225
|
+
*/
|
|
226
|
+
export function rgbaToHex(r, g, b) {
|
|
227
|
+
const hexR = (r < 16 ? "0" : "") + r.toString(16);
|
|
228
|
+
const hexG = (g < 16 ? "0" : "") + g.toString(16);
|
|
229
|
+
const hexB = (b < 16 ? "0" : "") + b.toString(16);
|
|
230
|
+
return `#${hexR}${hexG}${hexB}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Преобразует масштабированный RGBA буфер в многострочный текст Blessed
|
|
235
|
+
* с использованием символов верхнего полублока (▀ U+2580).
|
|
236
|
+
*
|
|
237
|
+
* Верхний пиксель ячейки задаётся через тег цвета текста (`{ #rrggbb-fg }`),
|
|
238
|
+
* нижний пиксель — через тег цвета фона (`{ #rrggbb-bg }`).
|
|
239
|
+
*
|
|
240
|
+
* @param {Uint8Array} data RGBA буфер
|
|
241
|
+
* @param {number} width Ширина в символах/пикселях
|
|
242
|
+
* @param {number} height Высота в пикселях (должна быть четной, = rows * 2)
|
|
243
|
+
* @returns {string}
|
|
244
|
+
*/
|
|
245
|
+
export function rgbaToHalfBlockBlessed(data, width, height) {
|
|
246
|
+
const rows = Math.floor(height / 2);
|
|
247
|
+
const lines = [];
|
|
248
|
+
|
|
249
|
+
for (let r = 0; r < rows; r++) {
|
|
250
|
+
let line = "";
|
|
251
|
+
let currentFg = null;
|
|
252
|
+
let currentBg = null;
|
|
253
|
+
|
|
254
|
+
const topRowOffset = (r * 2) * width * 4;
|
|
255
|
+
const botRowOffset = (r * 2 + 1) * width * 4;
|
|
256
|
+
|
|
257
|
+
for (let x = 0; x < width; x++) {
|
|
258
|
+
const topOffset = topRowOffset + x * 4;
|
|
259
|
+
const botOffset = botRowOffset + x * 4;
|
|
260
|
+
|
|
261
|
+
const topHex = rgbaToHex(data[topOffset], data[topOffset + 1], data[topOffset + 2]);
|
|
262
|
+
const botHex = rgbaToHex(data[botOffset], data[botOffset + 1], data[botOffset + 2]);
|
|
263
|
+
|
|
264
|
+
if (topHex !== currentFg) {
|
|
265
|
+
line += `{${topHex}-fg}`;
|
|
266
|
+
currentFg = topHex;
|
|
267
|
+
}
|
|
268
|
+
if (botHex !== currentBg) {
|
|
269
|
+
line += `{${botHex}-bg}`;
|
|
270
|
+
currentBg = botHex;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
line += "▀";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (currentFg || currentBg) {
|
|
277
|
+
line += "{/}";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
lines.push(line);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Выполняет полный цикл декодирования, масштабирования и рендеринга изображения в Blessed-строку.
|
|
288
|
+
* @param {Buffer|Uint8Array} buffer Исходный буфер изображения (JPEG или PNG)
|
|
289
|
+
* @param {object} [options]
|
|
290
|
+
* @param {string} [options.mimeType=""] MIME-тип изображения
|
|
291
|
+
* @param {number} [options.maxWidth=36] Максимальная ширина
|
|
292
|
+
* @param {number} [options.maxHeight=14] Максимальная высота
|
|
293
|
+
* @param {string} [options.cacheKey] Ключ для сохранения в кэш
|
|
294
|
+
* @returns {string}
|
|
295
|
+
*/
|
|
296
|
+
export function renderImageBuffer(buffer, { mimeType = "", maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
|
|
297
|
+
if (cacheKey && imagePreviewCache.has(cacheKey)) {
|
|
298
|
+
return imagePreviewCache.get(cacheKey);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const decoded = decodeImageBuffer(buffer, mimeType);
|
|
303
|
+
const { dstW, dstH } = calculateTargetDimensions(decoded.width, decoded.height, maxWidth, maxHeight);
|
|
304
|
+
const resized = resizeRgba(decoded.data, decoded.width, decoded.height, dstW, dstH);
|
|
305
|
+
const blessedText = rgbaToHalfBlockBlessed(resized, dstW, dstH);
|
|
306
|
+
|
|
307
|
+
if (cacheKey) {
|
|
308
|
+
if (imagePreviewCache.size >= MAX_CACHE_SIZE) {
|
|
309
|
+
const firstKey = imagePreviewCache.keys().next().value;
|
|
310
|
+
imagePreviewCache.delete(firstKey);
|
|
311
|
+
}
|
|
312
|
+
imagePreviewCache.set(cacheKey, blessedText);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return blessedText;
|
|
316
|
+
} catch {
|
|
317
|
+
return "";
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Синхронно распаковывает и рендерит PhotoStrippedSize Telegram в Blessed Half-Block строку.
|
|
323
|
+
* @param {Buffer|Uint8Array} strippedBytes Байты PhotoStrippedSize
|
|
324
|
+
* @param {object} [options]
|
|
325
|
+
* @param {number} [options.maxWidth=36]
|
|
326
|
+
* @param {number} [options.maxHeight=14]
|
|
327
|
+
* @param {string} [options.cacheKey]
|
|
328
|
+
* @returns {string}
|
|
329
|
+
*/
|
|
330
|
+
export function renderStrippedThumbnail(strippedBytes, { maxWidth = 36, maxHeight = 14, cacheKey } = {}) {
|
|
331
|
+
if (!strippedBytes || strippedBytes.length < 3) return "";
|
|
332
|
+
|
|
333
|
+
if (cacheKey && imagePreviewCache.has(cacheKey)) {
|
|
334
|
+
return imagePreviewCache.get(cacheKey);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const jpgBuf = strippedPhotoToJpg(strippedBytes);
|
|
339
|
+
return renderImageBuffer(jpgBuf, { mimeType: "image/jpeg", maxWidth, maxHeight, cacheKey });
|
|
340
|
+
} catch {
|
|
341
|
+
return "";
|
|
342
|
+
}
|
|
343
|
+
}
|