@emaxe/tuigram 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +15 -0
- package/CHANGELOG.md +63 -1
- package/CHANGELOG.ru.md +67 -1
- package/README.md +96 -15
- package/README.ru.md +97 -15
- package/package.json +2 -1
- package/src/cli/videoSetup.js +187 -0
- package/src/config.js +6 -0
- package/src/index.js +8 -0
- package/src/state.js +31 -8
- package/src/telegram/dialogs.js +6 -2
- package/src/telegram/formatter.js +1 -1
- package/src/telegram/messages.js +182 -19
- package/src/ui/app.js +293 -73
- package/src/ui/components/chatList.js +159 -101
- package/src/ui/components/chatView.js +454 -109
- package/src/ui/components/header.js +2 -1
- package/src/ui/components/inputBox.js +14 -5
- package/src/ui/components/modals/actionModal.js +11 -14
- package/src/ui/components/modals/chatInfoModal.js +3 -13
- package/src/ui/components/modals/confirmModal.js +3 -13
- package/src/ui/components/modals/fileModal.js +6 -13
- package/src/ui/components/modals/filePickerModal.js +3 -13
- package/src/ui/components/modals/helpModal.js +14 -18
- package/src/ui/components/modals/imageViewerModal.js +172 -0
- package/src/ui/components/modals/videoPlayerModal.js +274 -0
- package/src/ui/components/statusBar.js +2 -1
- package/src/ui/modalMouse.js +43 -0
- package/src/ui/screen.js +168 -20
- package/src/ui/theme.js +1 -0
- package/src/utils/image.js +318 -14
- package/src/utils/mouse.js +62 -75
- package/src/utils/video.js +495 -0
|
@@ -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
|
-
|
|
67
|
-
|
|
222
|
+
screen.mouseCaptured = true;
|
|
223
|
+
writeSeq(screen, ENABLE_MOUSE_SEQ);
|
|
224
|
+
screen.enableMouse();
|
|
68
225
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
screen.
|
|
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
|
-
|
|
95
|
-
screen.program.output.write(disableMouseSeq);
|
|
96
|
-
}
|
|
97
|
-
screen.program.disableMouse();
|
|
245
|
+
setMouseCapture(screen, false);
|
|
98
246
|
onExit?.();
|
|
99
247
|
} catch {
|
|
100
248
|
// Выходим в любом случае
|