@emaxe/tuigram 1.3.0 → 1.4.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.
@@ -2,7 +2,7 @@ import blessed from "neo-blessed";
2
2
  import { escapeBlessed } from "../../telegram/formatter.js";
3
3
  import { fg } from "../theme.js";
4
4
 
5
- import { getHeaderActionAt } from "../../utils/mouse.js";
5
+ import { getHeaderActionAt, isRightClick } from "../../utils/mouse.js";
6
6
 
7
7
  /**
8
8
  * Верхняя шапка приложения с информацией о пользователе, активном чате и статусе соединения.
@@ -39,6 +39,7 @@ export function createHeader(screen, theme, { onHelp, onChatInfo, onStatusClick
39
39
  });
40
40
 
41
41
  headerBox.on("click", (data) => {
42
+ if (isRightClick(data)) return;
42
43
  const relX = data.x - (headerBox.aleft || 0);
43
44
  const relY = data.y - (headerBox.atop || 0);
44
45
  const action = getHeaderActionAt(relX, relY, { hasActiveChat: Boolean(currentActiveChat) });
@@ -2,7 +2,7 @@ import blessed from "neo-blessed";
2
2
  import { escapeBlessed } from "../../telegram/formatter.js";
3
3
  import { fg, badge } from "../theme.js";
4
4
 
5
- import { getInputContextActionAt } from "../../utils/mouse.js";
5
+ import { getInputContextActionAt, isRightClick } from "../../utils/mouse.js";
6
6
 
7
7
  /**
8
8
  * Создаёт компонент поля ввода сообщения (нижняя панель).
@@ -124,6 +124,7 @@ export function createInputBox(screen, theme, {
124
124
  }
125
125
 
126
126
  contextBar.on("click", (data) => {
127
+ if (isRightClick(data)) return;
127
128
  const relX = data.x - (contextBar.aleft || 0);
128
129
  const action = getInputContextActionAt(relX, currentMode);
129
130
  if (action === "cancel") {
@@ -2,7 +2,8 @@ import blessed from "neo-blessed";
2
2
  import { escapeBlessed } from "../../../telegram/formatter.js";
3
3
  import { fg } from "../../theme.js";
4
4
 
5
- import { isInsideBox } from "../../../utils/mouse.js";
5
+ import { isRightClick } from "../../../utils/mouse.js";
6
+ import { bindOutsideClickClose } from "../../modalMouse.js";
6
7
 
7
8
  /**
8
9
  * Создаёт модальное окно контекстных действий над сообщением.
@@ -72,7 +73,8 @@ export function createActionModal(screen, theme, { onAction } = {}) {
72
73
  const baseCreateItem = list.createItem.bind(list);
73
74
  list.createItem = (content) => {
74
75
  const item = baseCreateItem(content);
75
- item.on("click", () => {
76
+ item.on("click", (data) => {
77
+ if (isRightClick(data)) return;
76
78
  const index = list.getItemIndex(item);
77
79
  const action = currentActions[index];
78
80
  if (action && currentMsg) {
@@ -128,6 +130,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
128
130
 
129
131
  list.setItems(currentActions.map((a) => a.label));
130
132
  previousFocus = screen.focused;
133
+ armOutsideClose();
131
134
  modal.show();
132
135
  modal.setFront();
133
136
  list.focus();
@@ -146,18 +149,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
146
149
  list.key(["escape", "q"], hide);
147
150
 
148
151
  // Закрытие при клике мышью мимо модального окна
149
- screen.on("click", (data) => {
150
- if (!modal.visible) return;
151
- const inside = isInsideBox(data.x, data.y, {
152
- left: modal.aleft,
153
- top: modal.atop,
154
- width: modal.width,
155
- height: modal.height,
156
- });
157
- if (!inside) {
158
- hide();
159
- }
160
- });
152
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
161
153
 
162
154
  return {
163
155
  modal,
@@ -3,7 +3,7 @@ import { escapeBlessed } from "../../../telegram/formatter.js";
3
3
  import { idToString } from "../../../telegram/entities.js";
4
4
  import { fg } from "../../theme.js";
5
5
 
6
- import { isInsideBox } from "../../../utils/mouse.js";
6
+ import { bindOutsideClickClose } from "../../modalMouse.js";
7
7
 
8
8
  /**
9
9
  * Создаёт модальное окно информации о текущем чате.
@@ -111,6 +111,7 @@ export function createChatInfoModal(screen, theme) {
111
111
 
112
112
  infoText.setContent(body);
113
113
  previousFocus = screen.focused;
114
+ armOutsideClose();
114
115
  modal.show();
115
116
  modal.setFront();
116
117
  closeBtn.focus();
@@ -123,18 +124,7 @@ export function createChatInfoModal(screen, theme) {
123
124
  closeBtn.key(["escape", "q"], hide);
124
125
 
125
126
  // Закрытие при клике мышью мимо модального окна
126
- screen.on("click", (data) => {
127
- if (!modal.visible) return;
128
- const inside = isInsideBox(data.x, data.y, {
129
- left: modal.aleft,
130
- top: modal.atop,
131
- width: modal.width,
132
- height: modal.height,
133
- });
134
- if (!inside) {
135
- hide();
136
- }
137
- });
127
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
138
128
 
139
129
  return {
140
130
  modal,
@@ -1,6 +1,6 @@
1
1
  import blessed from "neo-blessed";
2
2
  import { fg } from "../../theme.js";
3
- import { isInsideBox } from "../../../utils/mouse.js";
3
+ import { bindOutsideClickClose } from "../../modalMouse.js";
4
4
 
5
5
  /**
6
6
  * Создаёт модальное окно подтверждения действия (Да / Нет).
@@ -130,18 +130,7 @@ export function createConfirmModal(screen, theme) {
130
130
  }
131
131
 
132
132
  // Закрытие при клике мышью мимо модального окна
133
- screen.on("click", (data) => {
134
- if (!modal.visible) return;
135
- const inside = isInsideBox(data.x, data.y, {
136
- left: modal.aleft,
137
- top: modal.atop,
138
- width: modal.width,
139
- height: modal.height,
140
- });
141
- if (!inside) {
142
- hide();
143
- }
144
- });
133
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
145
134
 
146
135
  return {
147
136
  modal,
@@ -149,6 +138,7 @@ export function createConfirmModal(screen, theme) {
149
138
  currentCallback = onConfirm;
150
139
  previousFocus = screen.focused;
151
140
  msgBox.setContent(`{bold}${text}{/bold}`);
141
+ armOutsideClose();
152
142
  modal.show();
153
143
  modal.setFront();
154
144
  noBtn.focus();
@@ -4,7 +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
+ import { bindOutsideClickClose } from "../../modalMouse.js";
8
8
 
9
9
  /** Разделитель нескольких путей в поле ввода. */
10
10
  const PATH_SEPARATOR = "|";
@@ -253,6 +253,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
253
253
  asDocumentCheck.uncheck();
254
254
  statusLine.setContent("");
255
255
  previousFocus = screen.focused;
256
+ armOutsideClose();
256
257
  modal.show();
257
258
  modal.setFront();
258
259
  pathInput.focus();
@@ -360,18 +361,10 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
360
361
  screen.render();
361
362
  });
362
363
 
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
- }
364
+ // Закрытие при клике мышью мимо модального окна.
365
+ // Пока открыт файловый пикер, клики мимо окна отправки его не закрывают.
366
+ const armOutsideClose = bindOutsideClickClose(screen, modal, () => {
367
+ if (!picker.modal.visible) hide();
375
368
  });
376
369
 
377
370
  return {
@@ -4,7 +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
+ import { bindOutsideClickClose } from "../../modalMouse.js";
8
8
 
9
9
  /**
10
10
  * Модальное окно выбора локального файла (обёртка над blessed.filemanager).
@@ -194,18 +194,7 @@ export function createFilePickerModal(screen, theme) {
194
194
  footer.on("click", hide);
195
195
 
196
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
- });
197
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
209
198
 
210
199
  return {
211
200
  modal,
@@ -219,6 +208,7 @@ export function createFilePickerModal(screen, theme) {
219
208
  onPickCallback = onPick;
220
209
  onCloseCallback = onClose;
221
210
  const cwd = startDir && fs.existsSync(startDir) ? startDir : os.homedir();
211
+ armOutsideClose();
222
212
  modal.show();
223
213
  modal.setFront();
224
214
  manager.refresh(cwd, () => {
@@ -1,5 +1,5 @@
1
1
  import blessed from "neo-blessed";
2
- import { isInsideBox } from "../../../utils/mouse.js";
2
+ import { bindOutsideClickClose } from "../../modalMouse.js";
3
3
 
4
4
  /**
5
5
  * Создаёт модальное окно справки по всем горячим клавишам и возможностям.
@@ -44,16 +44,21 @@ export function createHelpModal(screen, theme) {
44
44
 
45
45
  {bold}Навигация и фокус:{/bold}
46
46
  ${K_CYAN}[Tab]${K_END} / ${K_CYAN}[Shift+Tab]${K_END} Фокус по кругу: Список чатов → Лента сообщений → Ввод
47
- ${K_CYAN}[↑] / [↓]${K_END} Перемещение по списку чатов
48
- ${K_CYAN}[Enter]${K_END} Открыть выбранный чат / Загрузить историю
47
+ ${K_CYAN}[↑] / [↓]${K_END} Список чатов: перемещение · Лента: выделение сообщения
48
+ ${K_CYAN}[Enter]${K_END} Открыть чат / Меню действий над выделенным сообщением
49
49
  ${K_CYAN}[PageUp] / [Ctrl+U]${K_END} Прокрутка сообщений вверх / Подгрузка старой истории
50
50
  ${K_CYAN}[PageDown] / [Ctrl+D]${K_END}Прокрутка сообщений вниз
51
+ ${K_CYAN}[Home] / [End]${K_END} Начало ленты (подгрузка истории) / Последнее сообщение
51
52
 
52
53
  {bold}Управление мышью:{/bold}
53
54
  ${K_CYAN}Клик по диалогу${K_END} Мгновенно открыть чат
54
55
  ${K_CYAN}Колесо мыши${K_END} Прокрутка списка чатов и сообщений (вверх — подгрузка истории)
55
- ${K_CYAN}Клик по сообщению${K_END} Открыть меню действий над сообщением
56
+ ${K_CYAN}Левый клик по сообщению${K_END} Выделить сообщение (помечается полосой ▌ слева)
57
+ ${K_CYAN}Правый клик по сообщению${K_END} Меню действий над сообщением
58
+ ${K_CYAN}Клик по картинке${K_END} Открыть изображение на весь экран (${K_CYAN}[Esc]${K_END} — закрыть)
56
59
  ${K_CYAN}Клик по вкладкам/кнопкам${K_END} Переключение фильтров и вызов действий
60
+ ${K_GRAY}macOS Terminal.app перехватывает правый клик — там пользуйтесь [Enter] или [Ctrl+A]${K_END}
61
+ ${K_CYAN}[F12]${K_END} Отдать мышь терминалу, чтобы выделить и скопировать текст
57
62
 
58
63
  {bold}Вкладки фильтрации диалогов (нажмите цифру в списке чатов):{/bold}
59
64
  ${K_YELLOW}[1]${K_END} Все чаты ${K_YELLOW}[2]${K_END} Личные (ЛС) ${K_YELLOW}[3]${K_END} Группы
@@ -63,8 +68,8 @@ export function createHelpModal(screen, theme) {
63
68
  {bold}Работа с сообщениями:{/bold}
64
69
  ${K_GREEN}[Enter]${K_END} Отправить набранный текст
65
70
  ${K_GREEN}[Ctrl+J]${K_END} Перенос строки без отправки
66
- ${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на последнее сообщение
67
- ${K_GREEN}[Ctrl+E]${K_END} Редактировать своё последнее сообщение
71
+ ${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на выделенное, иначе — на последнее
72
+ ${K_GREEN}[Ctrl+E]${K_END} Редактировать выделенное своё, иначе — последнее своё
68
73
  ${K_GREEN}[Ctrl+A]${K_END} Контекстное меню действий (Реакции, Удаление, Скачивание)
69
74
  ${K_GREEN}[Ctrl+O]${K_END} Отправить файл / картинку / документ
70
75
  ${K_GREEN} [Ctrl+F]${K_END} — в окне отправки: обзор файлов
@@ -122,6 +127,7 @@ export function createHelpModal(screen, theme) {
122
127
 
123
128
  function show() {
124
129
  previousFocus = screen.focused;
130
+ armOutsideClose();
125
131
  modal.show();
126
132
  modal.setFront();
127
133
  closeBtn.focus();
@@ -134,18 +140,7 @@ export function createHelpModal(screen, theme) {
134
140
  closeBtn.key(["escape", "q", "f1"], hide);
135
141
 
136
142
  // Закрытие при клике мышью мимо модального окна
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
- });
143
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
149
144
 
150
145
  return {
151
146
  modal,
@@ -0,0 +1,172 @@
1
+ import blessed from "neo-blessed";
2
+ import { fg } from "../../theme.js";
3
+ import { renderImageBuffer } from "../../../utils/image.js";
4
+
5
+ /**
6
+ * Создаёт полноэкранный просмотрщик изображений.
7
+ *
8
+ * Сначала показывается встроенная в сообщение миниатюра (мгновенно, без сети),
9
+ * затем она заменяется полноразмерной версией, как только та скачается.
10
+ *
11
+ * @param {blessed.Widgets.Screen} screen
12
+ * @param {object} theme
13
+ * @param {object} callbacks
14
+ * @param {(msg: object) => Promise<{ buffer: Buffer, mimeType: string }>} [callbacks.onLoadFullImage]
15
+ * @param {(msg: object, size: { maxWidth: number, maxHeight: number }) => string} [callbacks.onRenderPlaceholder]
16
+ * @returns {{ modal: object, show: (msg: object) => void, hide: () => void, isVisible: () => boolean }}
17
+ */
18
+ export function createImageViewerModal(screen, theme, { onLoadFullImage, onRenderPlaceholder } = {}) {
19
+ const modal = blessed.box({
20
+ parent: screen,
21
+ top: 0,
22
+ left: 0,
23
+ width: "100%",
24
+ height: "100%",
25
+ hidden: true,
26
+ mouse: true,
27
+ style: {
28
+ bg: theme.bg,
29
+ fg: theme.fg,
30
+ },
31
+ });
32
+
33
+ const canvas = blessed.box({
34
+ parent: modal,
35
+ top: 0,
36
+ left: 0,
37
+ right: 0,
38
+ bottom: 1,
39
+ tags: true,
40
+ align: "center",
41
+ valign: "middle",
42
+ style: {
43
+ bg: theme.bg,
44
+ fg: theme.fg,
45
+ },
46
+ });
47
+
48
+ const footer = blessed.box({
49
+ parent: modal,
50
+ bottom: 0,
51
+ left: 0,
52
+ right: 0,
53
+ height: 1,
54
+ tags: true,
55
+ style: {
56
+ bg: theme.status.bg,
57
+ fg: theme.status.fg,
58
+ },
59
+ });
60
+
61
+ let currentMsg = null;
62
+ let fullImage = null;
63
+ let statusText = "";
64
+ let previousFocus = null;
65
+ /** Счётчик открытий: результат опоздавшей загрузки не должен затирать новую картинку. */
66
+ let token = 0;
67
+
68
+ /** Размер холста в ячейках терминала. */
69
+ function viewportSize() {
70
+ return {
71
+ maxWidth: Math.max(8, (screen.width || 80) - 2),
72
+ maxHeight: Math.max(4, (screen.height || 24) - 2),
73
+ };
74
+ }
75
+
76
+ function renderFooter() {
77
+ const id = currentMsg ? `#${currentMsg.id}` : "";
78
+ const status = statusText ? `${fg(theme.warning, statusText)} ${fg(theme.dim, "│")} ` : "";
79
+ footer.setContent(
80
+ ` ${fg(theme.accent, id)} ${fg(theme.dim, "│")} ${status}${fg(theme.muted, "[Esc] закрыть · клик — закрыть")}`
81
+ );
82
+ }
83
+
84
+ /** Перерисовывает картинку под текущий размер терминала. */
85
+ function repaint() {
86
+ if (!currentMsg) return;
87
+ const size = viewportSize();
88
+
89
+ let content = "";
90
+ if (fullImage?.buffer) {
91
+ content = renderImageBuffer(fullImage.buffer, {
92
+ mimeType: fullImage.mimeType,
93
+ maxWidth: size.maxWidth,
94
+ maxHeight: size.maxHeight,
95
+ });
96
+ }
97
+ if (!content) {
98
+ content = onRenderPlaceholder?.(currentMsg, size) || "";
99
+ }
100
+ if (!content) {
101
+ content = fg(theme.muted, "Изображение недоступно");
102
+ }
103
+
104
+ canvas.setContent(content);
105
+ renderFooter();
106
+ screen.render();
107
+ }
108
+
109
+ function hide() {
110
+ // Инвалидируем незавершённую загрузку: её результат уже не нужен
111
+ token++;
112
+ modal.hide();
113
+ currentMsg = null;
114
+ fullImage = null;
115
+ statusText = "";
116
+ canvas.setContent("");
117
+ if (previousFocus) {
118
+ previousFocus.focus();
119
+ previousFocus = null;
120
+ }
121
+ screen.render();
122
+ }
123
+
124
+ /**
125
+ * Открывает изображение сообщения на весь экран.
126
+ * @param {object} msg нормализованное сообщение
127
+ */
128
+ function show(msg) {
129
+ if (!msg) return;
130
+
131
+ const myToken = ++token;
132
+ currentMsg = msg;
133
+ fullImage = null;
134
+ statusText = "Загрузка полного изображения...";
135
+ previousFocus = screen.focused;
136
+
137
+ repaint();
138
+ modal.show();
139
+ modal.setFront();
140
+ modal.focus();
141
+ screen.render();
142
+
143
+ Promise.resolve()
144
+ .then(() => onLoadFullImage?.(msg))
145
+ .then((result) => {
146
+ if (myToken !== token || !result?.buffer) return;
147
+ fullImage = result;
148
+ statusText = "";
149
+ repaint();
150
+ })
151
+ .catch((err) => {
152
+ if (myToken !== token) return;
153
+ statusText = `Не удалось загрузить: ${err.message}`;
154
+ renderFooter();
155
+ screen.render();
156
+ });
157
+ }
158
+
159
+ modal.key(["escape", "q", "enter", "return", "space"], hide);
160
+ modal.on("click", hide);
161
+
162
+ screen.on("resize", () => {
163
+ if (modal.visible) repaint();
164
+ });
165
+
166
+ return {
167
+ modal,
168
+ show,
169
+ hide,
170
+ isVisible: () => modal.visible,
171
+ };
172
+ }
@@ -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
@@ -15,10 +15,58 @@ export const GLOBAL_KEYS = [
15
15
  "C-r",
16
16
  "C-e",
17
17
  "C-p",
18
+ // C-a — меню действий над выделенным сообщением, f12 — тумблер захвата мыши
19
+ "C-a",
20
+ "f12",
18
21
  // escape — чтобы можно было прервать отправку файла из любого места
19
22
  "escape",
20
23
  ];
21
24
 
25
+ /**
26
+ * Включение мыши: 1000 — кнопки, 1002 — клики/перетаскивание/колесо,
27
+ * 1006 — SGR-кодирование координат, 1015 — urxvt как запасной вариант.
28
+ * Режимы 1003 (все движения) и 1005 (UTF-8) явно гасим: они ломают разбор в blessed.
29
+ */
30
+ const ENABLE_MOUSE_SEQ = "\x1b[?1003l\x1b[?1005l\x1b[?1000h\x1b[?1002h\x1b[?1006h\x1b[?1015h";
31
+ const DISABLE_MOUSE_SEQ = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1005l\x1b[?1006l\x1b[?1015l";
32
+
33
+ /**
34
+ * Пишет escape-последовательность напрямую в терминал.
35
+ * @param {import("neo-blessed").Widgets.Screen} screen
36
+ * @param {string} seq
37
+ */
38
+ function writeSeq(screen, seq) {
39
+ try {
40
+ if (screen.program?.output && typeof screen.program.output.write === "function") {
41
+ screen.program.output.write(seq);
42
+ }
43
+ } catch {
44
+ // Игнорируем в headless/тестах
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Включает или выключает захват мыши терминалом приложения.
50
+ * Пока захват включён, терминал не отдаёт пользователю выделение текста мышью,
51
+ * поэтому нужен способ временно его отпустить (F12).
52
+ * @param {import("neo-blessed").Widgets.Screen} screen
53
+ * @param {boolean} enabled
54
+ */
55
+ export function setMouseCapture(screen, enabled) {
56
+ screen.mouseCaptured = enabled;
57
+ if (enabled) {
58
+ writeSeq(screen, ENABLE_MOUSE_SEQ);
59
+ screen.program.enableMouse();
60
+ } else {
61
+ try {
62
+ screen.program.disableMouse();
63
+ } catch {
64
+ // Игнорируем в headless/тестах
65
+ }
66
+ writeSeq(screen, DISABLE_MOUSE_SEQ);
67
+ }
68
+ }
69
+
22
70
  /**
23
71
  * Создаёт и настраивает главный экран терминального интерфейса.
24
72
  * @param {object} [options]
@@ -63,25 +111,20 @@ export function createScreen({ theme, onExit } = {}) {
63
111
  utfMouse: false,
64
112
  }, true);
65
113
 
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";
114
+ screen.mouseCaptured = true;
115
+ writeSeq(screen, ENABLE_MOUSE_SEQ);
116
+ screen.enableMouse();
68
117
 
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
- }
118
+ // Режимы мыши переотправляются после каждой перерисовки и при ресайзе.
119
+ // Это выглядит избыточным, но без этого часть терминалов перестаёт слать
120
+ // события мыши приложению: попытка убрать переотправку ломала клики целиком.
121
+ // Не удалять без проверки в живом терминале — headless-тесты этого не ловят.
122
+ function restoreMouse() {
123
+ if (screen.mouseCaptured) writeSeq(screen, ENABLE_MOUSE_SEQ);
77
124
  }
78
125
 
79
- sendEnableMouse();
80
- screen.enableMouse();
81
-
82
- // Отправляем escape-последовательности повторно при перерендере/ресайзе экрана
83
- screen.on("render", sendEnableMouse);
84
- screen.on("resize", sendEnableMouse);
126
+ screen.on("render", restoreMouse);
127
+ screen.on("resize", restoreMouse);
85
128
 
86
129
  // Пробрасываем событие click на уровне экрана при mouseup
87
130
  screen.on("mouseup", (data) => {
@@ -91,10 +134,7 @@ export function createScreen({ theme, onExit } = {}) {
91
134
  // Обработка закрытия терминала или аварийного прерывания
92
135
  function cleanExit() {
93
136
  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();
137
+ setMouseCapture(screen, false);
98
138
  onExit?.();
99
139
  } catch {
100
140
  // Выходим в любом случае