@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.
@@ -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
- screen.key(["C-c"], () => {
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
+ }