@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.
@@ -0,0 +1,274 @@
1
+ import blessed from "neo-blessed";
2
+ import { fg } from "../../theme.js";
3
+ import { config } from "../../../config.js";
4
+ import { findFfmpegPath, findFfplayPath, spawnVideoPlayer, spawnAudioPlayer } from "../../../utils/video.js";
5
+ import { formatDuration } from "../../../utils/time.js";
6
+
7
+ /**
8
+ * Создаёт полноэкранное модальное окно воспроизведения видео в терминале.
9
+ *
10
+ * Декодирует видеопоток через ffmpeg и выводит кадры в Unicode Half-Block псевдографике,
11
+ * синхронизируя аудиодорожку через системный аудиоплеер (afplay / ffplay).
12
+ *
13
+ * @param {blessed.Widgets.Screen} screen
14
+ * @param {object} theme
15
+ * @param {object} callbacks
16
+ * @param {(msg: object, onProgress?: (p: number) => void) => Promise<string>} [callbacks.onLoadVideoFile]
17
+ * @param {(msg: object, size: { maxWidth: number, maxHeight: number }) => string} [callbacks.onRenderPlaceholder]
18
+ * @returns {{ modal: object, play: (msg: object) => void, hide: () => void, isVisible: () => boolean }}
19
+ */
20
+ export function createVideoPlayerModal(screen, theme, { onLoadVideoFile, onRenderPlaceholder } = {}) {
21
+ const modal = blessed.box({
22
+ parent: screen,
23
+ top: 0,
24
+ left: 0,
25
+ width: "100%",
26
+ height: "100%",
27
+ hidden: true,
28
+ mouse: true,
29
+ style: {
30
+ bg: theme.bg,
31
+ fg: theme.fg,
32
+ },
33
+ });
34
+
35
+ const canvas = blessed.box({
36
+ parent: modal,
37
+ top: 0,
38
+ left: 0,
39
+ right: 0,
40
+ bottom: 1,
41
+ tags: true,
42
+ align: "center",
43
+ valign: "middle",
44
+ style: {
45
+ bg: theme.bg,
46
+ fg: theme.fg,
47
+ },
48
+ });
49
+
50
+ const footer = blessed.box({
51
+ parent: modal,
52
+ bottom: 0,
53
+ left: 0,
54
+ right: 0,
55
+ height: 1,
56
+ tags: true,
57
+ style: {
58
+ bg: theme.status.bg,
59
+ fg: theme.status.fg,
60
+ },
61
+ });
62
+
63
+ let currentMsg = null;
64
+ let currentVideoPath = null;
65
+ let videoProcess = null;
66
+ let audioProcess = null;
67
+ let isPaused = false;
68
+ let statusText = "";
69
+ let previousFocus = null;
70
+ let token = 0;
71
+ let frameCount = 0;
72
+
73
+ /** Размер холста видео в ячейках/пикселях. */
74
+ function viewportDimensions() {
75
+ const screenW = screen.width || 80;
76
+ const screenH = screen.height || 24;
77
+ const width = Math.max(10, Math.min(screenW - 2, 80));
78
+ // Высота в пикселях: 2 пикселя на 1 терминальную строку
79
+ let height = Math.max(8, Math.min((screenH - 2) * 2, 60));
80
+ if (height % 2 !== 0) height -= 1;
81
+ return { width, height };
82
+ }
83
+
84
+ function renderFooter() {
85
+ const id = currentMsg ? `#${currentMsg.id}` : "";
86
+ const status = statusText ? `${fg(theme.warning, statusText)} ${fg(theme.dim, "│")} ` : "";
87
+ const pauseTag = isPaused ? `${fg(theme.danger, "[ПАУЗА]")} ${fg(theme.dim, "│")} ` : "";
88
+ const fpsTag = videoProcess ? `${fg(theme.dim, `FPS: ${config.videoFps}`)} ${fg(theme.dim, "│")} ` : "";
89
+
90
+ footer.setContent(
91
+ ` ${fg(theme.accent, id)} ${fg(theme.dim, "│")} ${pauseTag}${status}${fpsTag}${fg(theme.muted, "[Space] Пауза · [r] С начала · [Esc/q] Закрыть")}`
92
+ );
93
+ }
94
+
95
+ function stopProcesses() {
96
+ if (videoProcess) {
97
+ videoProcess.kill();
98
+ videoProcess = null;
99
+ }
100
+ if (audioProcess) {
101
+ audioProcess.kill();
102
+ audioProcess = null;
103
+ }
104
+ isPaused = false;
105
+ }
106
+
107
+ function hide() {
108
+ token++;
109
+ stopProcesses();
110
+ modal.hide();
111
+ currentMsg = null;
112
+ currentVideoPath = null;
113
+ statusText = "";
114
+ frameCount = 0;
115
+ canvas.setContent("");
116
+ if (previousFocus) {
117
+ previousFocus.focus();
118
+ previousFocus = null;
119
+ }
120
+ screen.render();
121
+ }
122
+
123
+ function togglePause() {
124
+ if (!videoProcess) return;
125
+ if (isPaused) {
126
+ videoProcess.resume();
127
+ audioProcess?.resume();
128
+ isPaused = false;
129
+ } else {
130
+ videoProcess.pause();
131
+ audioProcess?.pause();
132
+ isPaused = true;
133
+ }
134
+ renderFooter();
135
+ screen.render();
136
+ }
137
+
138
+ function startPlayback(filePath, myToken) {
139
+ if (myToken !== token) return;
140
+ stopProcesses();
141
+
142
+ const ffmpegPath = findFfmpegPath();
143
+ if (!ffmpegPath) {
144
+ statusText = "ffmpeg не найден. Запустите: tuigram install-video";
145
+ renderFooter();
146
+ screen.render();
147
+ return;
148
+ }
149
+
150
+ const { width, height } = viewportDimensions();
151
+ statusText = "";
152
+ frameCount = 0;
153
+ renderFooter();
154
+
155
+ // Запуск аудиодорожки
156
+ if (config.videoAudio) {
157
+ const ffplayPath = findFfplayPath();
158
+ audioProcess = spawnAudioPlayer(filePath, { ffplayPath });
159
+ }
160
+
161
+ // Запуск декодирования видео
162
+ videoProcess = spawnVideoPlayer(ffmpegPath, filePath, {
163
+ width,
164
+ height,
165
+ fps: config.videoFps,
166
+ onFrame: (frameText) => {
167
+ if (myToken !== token) return;
168
+ frameCount++;
169
+ canvas.setContent(frameText);
170
+ renderFooter();
171
+ screen.render();
172
+ },
173
+ onEnd: () => {
174
+ if (myToken !== token) return;
175
+ statusText = "Воспроизведение завершено";
176
+ if (audioProcess) {
177
+ audioProcess.kill();
178
+ audioProcess = null;
179
+ }
180
+ renderFooter();
181
+ screen.render();
182
+ },
183
+ onError: (err) => {
184
+ if (myToken !== token) return;
185
+ statusText = `Ошибка декодирования: ${err.message}`;
186
+ if (audioProcess) {
187
+ audioProcess.kill();
188
+ audioProcess = null;
189
+ }
190
+ renderFooter();
191
+ screen.render();
192
+ },
193
+ });
194
+ }
195
+
196
+ /**
197
+ * Открывает полноэкранный плеер для воспроизведения видеосообщения.
198
+ * @param {object} msg нормализованное сообщение
199
+ */
200
+ async function play(msg) {
201
+ if (!msg) return;
202
+
203
+ const myToken = ++token;
204
+ stopProcesses();
205
+
206
+ currentMsg = msg;
207
+ currentVideoPath = null;
208
+ previousFocus = screen.focused;
209
+ isPaused = false;
210
+
211
+ // Показываем статичную миниатюру на время подготовки
212
+ const { width, height } = viewportDimensions();
213
+ const placeholder = onRenderPlaceholder?.(msg, { maxWidth: width, maxHeight: Math.floor(height / 2) }) || "";
214
+ canvas.setContent(placeholder);
215
+
216
+ modal.show();
217
+ modal.setFront();
218
+ modal.focus();
219
+
220
+ if (!config.enableVideo) {
221
+ statusText = "Проигрывание видео выключено. Включите ENABLE_VIDEO=true в .env";
222
+ renderFooter();
223
+ screen.render();
224
+ return;
225
+ }
226
+
227
+ const ffmpegPath = findFfmpegPath();
228
+ if (!ffmpegPath) {
229
+ statusText = "ffmpeg не найден. Запустите: tuigram install-video";
230
+ renderFooter();
231
+ screen.render();
232
+ return;
233
+ }
234
+
235
+ statusText = "Загрузка видеофайла...";
236
+ renderFooter();
237
+ screen.render();
238
+
239
+ try {
240
+ const filePath = await onLoadVideoFile?.(msg, (p) => {
241
+ if (myToken !== token) return;
242
+ const percent = Math.round((p || 0) * 100);
243
+ statusText = `Загрузка: ${percent}%...`;
244
+ renderFooter();
245
+ screen.render();
246
+ });
247
+
248
+ if (myToken !== token || !filePath) return;
249
+ currentVideoPath = filePath;
250
+ startPlayback(filePath, myToken);
251
+ } catch (err) {
252
+ if (myToken !== token) return;
253
+ statusText = `Ошибка загрузки: ${err.message}`;
254
+ renderFooter();
255
+ screen.render();
256
+ }
257
+ }
258
+
259
+ modal.key(["space"], togglePause);
260
+ modal.key(["r", "R"], () => {
261
+ if (currentVideoPath) {
262
+ startPlayback(currentVideoPath, token);
263
+ }
264
+ });
265
+ modal.key(["escape", "q"], hide);
266
+ modal.on("click", togglePause);
267
+
268
+ return {
269
+ modal,
270
+ play,
271
+ hide,
272
+ isVisible: () => modal.visible,
273
+ };
274
+ }
@@ -1,7 +1,7 @@
1
1
  import blessed from "neo-blessed";
2
2
  import { fg } from "../theme.js";
3
3
 
4
- import { getStatusBarActionAt } from "../../utils/mouse.js";
4
+ import { getStatusBarActionAt, isRightClick } from "../../utils/mouse.js";
5
5
 
6
6
  /**
7
7
  * Создаёт нижнюю строку состояния (Status Bar) с подсказками и временными тостами.
@@ -43,6 +43,7 @@ export function createStatusBar(screen, theme, {
43
43
  });
44
44
 
45
45
  statusBar.on("click", (data) => {
46
+ if (isRightClick(data)) return;
46
47
  const relX = data.x - (statusBar.aleft || 0);
47
48
  const action = getStatusBarActionAt(relX, statusBar.width || 120);
48
49
  switch (action) {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Общее мышиное поведение модальных окон.
3
+ */
4
+
5
+ import { isInsideBox } from "../utils/mouse.js";
6
+
7
+ /**
8
+ * Подключает закрытие модального окна по клику мимо него.
9
+ *
10
+ * neo-blessed рассылает клик сначала элементу под курсором, а сразу за ним, в том же
11
+ * такте, — экрану (см. Screen.prototype._listenMouse). Поэтому клик, которым окно
12
+ * открыли (кнопка строки состояния, шапка, сообщение в ленте), доходил бы до этого
13
+ * обработчика и закрывал только что открытое окно. Возвращаемая функция «взводит»
14
+ * закрытие лишь на следующем такте — её вызывает show() окна.
15
+ *
16
+ * @param {import("neo-blessed").Widgets.Screen} screen
17
+ * @param {import("neo-blessed").Widgets.BoxElement} modal
18
+ * @param {() => void} hide
19
+ * @returns {() => void} arm — взводит закрытие по внешнему клику
20
+ */
21
+ export function bindOutsideClickClose(screen, modal, hide) {
22
+ let armed = false;
23
+
24
+ screen.on("click", (data) => {
25
+ if (!armed || !modal.visible) return;
26
+ const inside = isInsideBox(data.x, data.y, {
27
+ left: modal.aleft,
28
+ top: modal.atop,
29
+ width: modal.width,
30
+ height: modal.height,
31
+ });
32
+ if (!inside) {
33
+ hide();
34
+ }
35
+ });
36
+
37
+ return () => {
38
+ armed = false;
39
+ setImmediate(() => {
40
+ armed = true;
41
+ });
42
+ };
43
+ }
package/src/ui/screen.js CHANGED
@@ -1,5 +1,113 @@
1
1
  import blessed from "neo-blessed";
2
2
 
3
+ // Патч neo-blessed: предотвращаем дублирование обработчиков keypress и циклы фокуса.
4
+ // В neo-blessed при повторном focus() на уже сфокусированном элементе происходил blur+focus,
5
+ // а readInput() создавал висячие обработчики keypress в nextTick, из-за чего вводимые символы
6
+ // дублировались (например, "ттуутт??").
7
+ if (!blessed.__tuigramPatched) {
8
+ blessed.__tuigramPatched = true;
9
+
10
+ const origElementFocus = blessed.element.prototype.focus;
11
+ blessed.element.prototype.focus = function() {
12
+ if (this.screen?.focused === this) return this;
13
+ return origElementFocus.call(this);
14
+ };
15
+
16
+ const origScreenFocusPush = blessed.screen.prototype.focusPush;
17
+ blessed.screen.prototype.focusPush = function(el) {
18
+ if (!el) return;
19
+ const old = this.history[this.history.length - 1];
20
+ if (old === el) return;
21
+ return origScreenFocusPush.call(this, el);
22
+ };
23
+
24
+ blessed.textarea.prototype.readInput = function(callback) {
25
+ const self = this;
26
+ const focused = this.screen.focused === this;
27
+
28
+ if (this._reading) return;
29
+ this._reading = true;
30
+
31
+ this._callback = callback;
32
+
33
+ if (!focused) {
34
+ this.screen.saveFocus();
35
+ this.focus();
36
+ }
37
+
38
+ this.screen.grabKeys = true;
39
+ this._updateCursor();
40
+ this.screen.program.showCursor();
41
+
42
+ if (this.__listener) {
43
+ this.removeListener("keypress", this.__listener);
44
+ delete this.__listener;
45
+ }
46
+ if (this.__done) {
47
+ this.removeListener("blur", this.__done);
48
+ delete this.__done;
49
+ }
50
+
51
+ this._done = function fn(err, value) {
52
+ if (!self._reading) return;
53
+
54
+ if (fn.done) return;
55
+ fn.done = true;
56
+
57
+ self._reading = false;
58
+
59
+ delete self._callback;
60
+ delete self._done;
61
+
62
+ if (self.__listener) {
63
+ self.removeListener("keypress", self.__listener);
64
+ delete self.__listener;
65
+ }
66
+ if (self.__done) {
67
+ self.removeListener("blur", self.__done);
68
+ delete self.__done;
69
+ }
70
+
71
+ self.screen.program.hideCursor();
72
+ self.screen.grabKeys = false;
73
+
74
+ if (!focused) {
75
+ self.screen.restoreFocus();
76
+ }
77
+
78
+ if (self.options.inputOnFocus) {
79
+ self.screen.rewindFocus();
80
+ }
81
+
82
+ if (err === "stop") return;
83
+
84
+ if (err) {
85
+ self.emit("error", err);
86
+ } else if (value != null) {
87
+ self.emit("submit", value);
88
+ } else {
89
+ self.emit("cancel", value);
90
+ }
91
+ self.emit("action", value);
92
+
93
+ if (!callback) return;
94
+ return err ? callback(err) : callback(null, value);
95
+ };
96
+
97
+ setImmediate(() => {
98
+ if (!self._reading) return;
99
+ if (self.__listener) {
100
+ self.removeListener("keypress", self.__listener);
101
+ }
102
+ self.__listener = self._listener.bind(self);
103
+ self.on("keypress", self.__listener);
104
+ });
105
+
106
+ this.__done = this._done.bind(this, null, null);
107
+ this.on("blur", this.__done);
108
+ };
109
+ }
110
+
3
111
  /**
4
112
  * Глобальные сочетания, которые должны работать даже когда поле ввода или строка
5
113
  * поиска перехватили клавиатуру (blessed выставляет screen.grabKeys = true и
@@ -15,10 +123,58 @@ export const GLOBAL_KEYS = [
15
123
  "C-r",
16
124
  "C-e",
17
125
  "C-p",
126
+ // C-a — меню действий над выделенным сообщением, f12 — тумблер захвата мыши
127
+ "C-a",
128
+ "f12",
18
129
  // escape — чтобы можно было прервать отправку файла из любого места
19
130
  "escape",
20
131
  ];
21
132
 
133
+ /**
134
+ * Включение мыши: 1000 — кнопки, 1002 — клики/перетаскивание/колесо,
135
+ * 1006 — SGR-кодирование координат, 1015 — urxvt как запасной вариант.
136
+ * Режимы 1003 (все движения) и 1005 (UTF-8) явно гасим: они ломают разбор в blessed.
137
+ */
138
+ const ENABLE_MOUSE_SEQ = "\x1b[?1003l\x1b[?1005l\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h";
139
+ const DISABLE_MOUSE_SEQ = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l";
140
+
141
+ /**
142
+ * Пишет escape-последовательность напрямую в терминал.
143
+ * @param {import("neo-blessed").Widgets.Screen} screen
144
+ * @param {string} seq
145
+ */
146
+ function writeSeq(screen, seq) {
147
+ try {
148
+ if (screen.program?.output && typeof screen.program.output.write === "function") {
149
+ screen.program.output.write(seq);
150
+ }
151
+ } catch {
152
+ // Игнорируем в headless/тестах
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Включает или выключает захват мыши терминалом приложения.
158
+ * Пока захват включён, терминал не отдаёт пользователю выделение текста мышью,
159
+ * поэтому нужен способ временно его отпустить (F12).
160
+ * @param {import("neo-blessed").Widgets.Screen} screen
161
+ * @param {boolean} enabled
162
+ */
163
+ export function setMouseCapture(screen, enabled) {
164
+ screen.mouseCaptured = enabled;
165
+ if (enabled) {
166
+ writeSeq(screen, ENABLE_MOUSE_SEQ);
167
+ screen.program.enableMouse();
168
+ } else {
169
+ try {
170
+ screen.program.disableMouse();
171
+ } catch {
172
+ // Игнорируем в headless/тестах
173
+ }
174
+ writeSeq(screen, DISABLE_MOUSE_SEQ);
175
+ }
176
+ }
177
+
22
178
  /**
23
179
  * Создаёт и настраивает главный экран терминального интерфейса.
24
180
  * @param {object} [options]
@@ -63,25 +219,20 @@ export function createScreen({ theme, onExit } = {}) {
63
219
  utfMouse: false,
64
220
  }, true);
65
221
 
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";
222
+ screen.mouseCaptured = true;
223
+ writeSeq(screen, ENABLE_MOUSE_SEQ);
224
+ screen.enableMouse();
68
225
 
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
- }
226
+ // Режимы мыши переотправляются после каждой перерисовки и при ресайзе.
227
+ // Это выглядит избыточным, но без этого часть терминалов перестаёт слать
228
+ // события мыши приложению: попытка убрать переотправку ломала клики целиком.
229
+ // Не удалять без проверки в живом терминале — headless-тесты этого не ловят.
230
+ function restoreMouse() {
231
+ if (screen.mouseCaptured) writeSeq(screen, ENABLE_MOUSE_SEQ);
77
232
  }
78
233
 
79
- sendEnableMouse();
80
- screen.enableMouse();
81
-
82
- // Отправляем escape-последовательности повторно при перерендере/ресайзе экрана
83
- screen.on("render", sendEnableMouse);
84
- screen.on("resize", sendEnableMouse);
234
+ screen.on("render", restoreMouse);
235
+ screen.on("resize", restoreMouse);
85
236
 
86
237
  // Пробрасываем событие click на уровне экрана при mouseup
87
238
  screen.on("mouseup", (data) => {
@@ -91,10 +242,7 @@ export function createScreen({ theme, onExit } = {}) {
91
242
  // Обработка закрытия терминала или аварийного прерывания
92
243
  function cleanExit() {
93
244
  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();
245
+ setMouseCapture(screen, false);
98
246
  onExit?.();
99
247
  } catch {
100
248
  // Выходим в любом случае
package/src/ui/theme.js CHANGED
@@ -68,6 +68,7 @@ function buildTheme(p) {
68
68
  outgoingName: p.green,
69
69
  time: p.dim,
70
70
  dateDivider: p.yellow,
71
+ unreadDivider: p.accent,
71
72
  replyBorder: p.muted,
72
73
  systemMsg: p.yellow,
73
74
  mediaFg: p.magenta,