@emaxe/tuigram 1.2.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.
@@ -1,5 +1,6 @@
1
1
  import blessed from "neo-blessed";
2
2
  import { fg } from "../../theme.js";
3
+ import { bindOutsideClickClose } from "../../modalMouse.js";
3
4
 
4
5
  /**
5
6
  * Создаёт модальное окно подтверждения действия (Да / Нет).
@@ -19,6 +20,7 @@ export function createConfirmModal(screen, theme) {
19
20
  height: 8,
20
21
  hidden: true,
21
22
  tags: true,
23
+ mouse: true,
22
24
  border: {
23
25
  type: "line",
24
26
  },
@@ -113,7 +115,9 @@ export function createConfirmModal(screen, theme) {
113
115
  }
114
116
 
115
117
  yesBtn.on("press", confirm);
118
+ yesBtn.on("click", confirm);
116
119
  noBtn.on("press", hide);
120
+ noBtn.on("click", hide);
117
121
 
118
122
  // Клавиши вешаем на обе кнопки — активна всегда одна из них.
119
123
  for (const btn of [yesBtn, noBtn]) {
@@ -125,12 +129,16 @@ export function createConfirmModal(screen, theme) {
125
129
  });
126
130
  }
127
131
 
132
+ // Закрытие при клике мышью мимо модального окна
133
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
134
+
128
135
  return {
129
136
  modal,
130
137
  ask: (text, onConfirm) => {
131
138
  currentCallback = onConfirm;
132
139
  previousFocus = screen.focused;
133
140
  msgBox.setContent(`{bold}${text}{/bold}`);
141
+ armOutsideClose();
134
142
  modal.show();
135
143
  modal.setFront();
136
144
  noBtn.focus();
@@ -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 { bindOutsideClickClose } from "../../modalMouse.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,
@@ -247,6 +253,7 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
247
253
  asDocumentCheck.uncheck();
248
254
  statusLine.setContent("");
249
255
  previousFocus = screen.focused;
256
+ armOutsideClose();
250
257
  modal.show();
251
258
  modal.setFront();
252
259
  pathInput.focus();
@@ -338,10 +345,28 @@ export function createFileModal(screen, theme, { onSendFile } = {}) {
338
345
  });
339
346
  captionInput.on("submit", submit);
340
347
  sendBtn.on("press", submit);
348
+ sendBtn.on("click", submit);
341
349
  cancelBtn.on("press", hide);
350
+ cancelBtn.on("click", hide);
342
351
  asDocumentCheck.on("check", () => validate({ quiet: true }));
343
352
  asDocumentCheck.on("uncheck", () => validate({ quiet: true }));
344
353
 
354
+ browseBar.on("click", openPicker);
355
+ pathInput.on("click", () => {
356
+ pathInput.focus();
357
+ screen.render();
358
+ });
359
+ captionInput.on("click", () => {
360
+ captionInput.focus();
361
+ screen.render();
362
+ });
363
+
364
+ // Закрытие при клике мышью мимо модального окна.
365
+ // Пока открыт файловый пикер, клики мимо окна отправки его не закрывают.
366
+ const armOutsideClose = bindOutsideClickClose(screen, modal, () => {
367
+ if (!picker.modal.visible) hide();
368
+ });
369
+
345
370
  return {
346
371
  modal,
347
372
  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 { bindOutsideClickClose } from "../../modalMouse.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,10 @@ 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
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
190
198
 
191
199
  return {
192
200
  modal,
@@ -200,6 +208,7 @@ export function createFilePickerModal(screen, theme) {
200
208
  onPickCallback = onPick;
201
209
  onCloseCallback = onClose;
202
210
  const cwd = startDir && fs.existsSync(startDir) ? startDir : os.homedir();
211
+ armOutsideClose();
203
212
  modal.show();
204
213
  modal.setFront();
205
214
  manager.refresh(cwd, () => {
@@ -1,4 +1,5 @@
1
1
  import blessed from "neo-blessed";
2
+ import { bindOutsideClickClose } from "../../modalMouse.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
  },
@@ -41,10 +44,21 @@ export function createHelpModal(screen, theme) {
41
44
 
42
45
  {bold}Навигация и фокус:{/bold}
43
46
  ${K_CYAN}[Tab]${K_END} / ${K_CYAN}[Shift+Tab]${K_END} Фокус по кругу: Список чатов → Лента сообщений → Ввод
44
- ${K_CYAN}[↑] / [↓]${K_END} Перемещение по списку чатов
45
- ${K_CYAN}[Enter]${K_END} Открыть выбранный чат / Загрузить историю
47
+ ${K_CYAN}[↑] / [↓]${K_END} Список чатов: перемещение · Лента: выделение сообщения
48
+ ${K_CYAN}[Enter]${K_END} Открыть чат / Меню действий над выделенным сообщением
46
49
  ${K_CYAN}[PageUp] / [Ctrl+U]${K_END} Прокрутка сообщений вверх / Подгрузка старой истории
47
50
  ${K_CYAN}[PageDown] / [Ctrl+D]${K_END}Прокрутка сообщений вниз
51
+ ${K_CYAN}[Home] / [End]${K_END} Начало ленты (подгрузка истории) / Последнее сообщение
52
+
53
+ {bold}Управление мышью:{/bold}
54
+ ${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} — закрыть)
59
+ ${K_CYAN}Клик по вкладкам/кнопкам${K_END} Переключение фильтров и вызов действий
60
+ ${K_GRAY}macOS Terminal.app перехватывает правый клик — там пользуйтесь [Enter] или [Ctrl+A]${K_END}
61
+ ${K_CYAN}[F12]${K_END} Отдать мышь терминалу, чтобы выделить и скопировать текст
48
62
 
49
63
  {bold}Вкладки фильтрации диалогов (нажмите цифру в списке чатов):{/bold}
50
64
  ${K_YELLOW}[1]${K_END} Все чаты ${K_YELLOW}[2]${K_END} Личные (ЛС) ${K_YELLOW}[3]${K_END} Группы
@@ -54,8 +68,8 @@ export function createHelpModal(screen, theme) {
54
68
  {bold}Работа с сообщениями:{/bold}
55
69
  ${K_GREEN}[Enter]${K_END} Отправить набранный текст
56
70
  ${K_GREEN}[Ctrl+J]${K_END} Перенос строки без отправки
57
- ${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на последнее сообщение
58
- ${K_GREEN}[Ctrl+E]${K_END} Редактировать своё последнее сообщение
71
+ ${K_GREEN}[Ctrl+R]${K_END} Ответить (Reply) на выделенное, иначе — на последнее
72
+ ${K_GREEN}[Ctrl+E]${K_END} Редактировать выделенное своё, иначе — последнее своё
59
73
  ${K_GREEN}[Ctrl+A]${K_END} Контекстное меню действий (Реакции, Удаление, Скачивание)
60
74
  ${K_GREEN}[Ctrl+O]${K_END} Отправить файл / картинку / документ
61
75
  ${K_GREEN} [Ctrl+F]${K_END} — в окне отправки: обзор файлов
@@ -113,6 +127,7 @@ export function createHelpModal(screen, theme) {
113
127
 
114
128
  function show() {
115
129
  previousFocus = screen.focused;
130
+ armOutsideClose();
116
131
  modal.show();
117
132
  modal.setFront();
118
133
  closeBtn.focus();
@@ -120,9 +135,13 @@ export function createHelpModal(screen, theme) {
120
135
  }
121
136
 
122
137
  closeBtn.on("press", hide);
138
+ closeBtn.on("click", hide);
123
139
  // Клавиши вешаем на кнопку: blessed отдаёт события только сфокусированному элементу.
124
140
  closeBtn.key(["escape", "q", "f1"], hide);
125
141
 
142
+ // Закрытие при клике мышью мимо модального окна
143
+ const armOutsideClose = bindOutsideClickClose(screen, modal, hide);
144
+
126
145
  return {
127
146
  modal,
128
147
  show,
@@ -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,13 +1,33 @@
1
1
  import blessed from "neo-blessed";
2
2
  import { fg } from "../theme.js";
3
3
 
4
+ import { getStatusBarActionAt, isRightClick } 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,45 @@ 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
+ if (isRightClick(data)) return;
47
+ const relX = data.x - (statusBar.aleft || 0);
48
+ const action = getStatusBarActionAt(relX, statusBar.width || 120);
49
+ switch (action) {
50
+ case "focus":
51
+ onFocusNext?.();
52
+ break;
53
+ case "select":
54
+ onSelectOrSubmit?.();
55
+ break;
56
+ case "tabs":
57
+ onFilterTabs?.();
58
+ break;
59
+ case "search":
60
+ onSearch?.();
61
+ break;
62
+ case "help":
63
+ onHelp?.();
64
+ break;
65
+ case "actions":
66
+ onAction?.();
67
+ break;
68
+ case "info":
69
+ onChatInfo?.();
70
+ break;
71
+ case "quit":
72
+ onQuit?.();
73
+ break;
74
+ }
75
+ });
76
+
24
77
  let currentTimeout = null;
25
78
 
26
79
  const key = (label) => `{bold}${fg(theme.accent, label)}{/bold}`;
@@ -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]
@@ -27,11 +75,15 @@ export const GLOBAL_KEYS = [
27
75
  * @returns {blessed.Widgets.Screen}
28
76
  */
29
77
  export function createScreen({ theme, onExit } = {}) {
78
+ // Форсируем поддержку SGR и CellMotion режимов в neo-blessed для современных терминалов
79
+ process.env.BLESSED_FORCE_MODES = "SGRMOUSE=1,CELLMOTION=1,VT200MOUSE=1,UTFMOUSE=0,ALLMOTION=0,URXVTMOUSE=1";
80
+
30
81
  const screen = blessed.screen({
31
82
  smartCSR: true,
32
83
  title: "TuiGram - Telegram Terminal Client",
33
84
  fullUnicode: true,
34
85
  dockBorders: true,
86
+ sendFocus: true,
35
87
  cursor: {
36
88
  synthetic: true,
37
89
  blink: true,
@@ -40,21 +92,58 @@ export function createScreen({ theme, onExit } = {}) {
40
92
  style: {
41
93
  bg: theme?.bg ?? "black",
42
94
  fg: theme?.fg ?? "white",
43
- }
95
+ },
44
96
  });
45
97
 
46
98
  screen.ignoreLocked = [...GLOBAL_KEYS];
47
99
 
100
+ // Настраиваем режимы мыши в program:
101
+ // SGR (1006) — стандарт для macOS Terminal, iTerm2, Alacritty, Kitty, Windows Terminal
102
+ // CellMotion (1002) — клики, зажатия и скролл
103
+ // VT200 (1000) — базовая поддержка кнопок
104
+ // urxvt (1015) — fallback
105
+ screen.program.setMouse({
106
+ vt200Mouse: true,
107
+ cellMotion: true,
108
+ allMotion: false,
109
+ sgrMouse: true,
110
+ urxvtMouse: true,
111
+ utfMouse: false,
112
+ }, true);
113
+
114
+ screen.mouseCaptured = true;
115
+ writeSeq(screen, ENABLE_MOUSE_SEQ);
116
+ screen.enableMouse();
117
+
118
+ // Режимы мыши переотправляются после каждой перерисовки и при ресайзе.
119
+ // Это выглядит избыточным, но без этого часть терминалов перестаёт слать
120
+ // события мыши приложению: попытка убрать переотправку ломала клики целиком.
121
+ // Не удалять без проверки в живом терминале — headless-тесты этого не ловят.
122
+ function restoreMouse() {
123
+ if (screen.mouseCaptured) writeSeq(screen, ENABLE_MOUSE_SEQ);
124
+ }
125
+
126
+ screen.on("render", restoreMouse);
127
+ screen.on("resize", restoreMouse);
128
+
129
+ // Пробрасываем событие click на уровне экрана при mouseup
130
+ screen.on("mouseup", (data) => {
131
+ screen.emit("click", data);
132
+ });
133
+
48
134
  // Обработка закрытия терминала или аварийного прерывания
49
- screen.key(["C-c"], () => {
135
+ function cleanExit() {
50
136
  try {
137
+ setMouseCapture(screen, false);
51
138
  onExit?.();
52
139
  } catch {
53
140
  // Выходим в любом случае
54
141
  }
55
142
  screen.destroy();
56
143
  process.exit(0);
57
- });
144
+ }
145
+
146
+ screen.key(["C-c"], cleanExit);
58
147
 
59
148
  return screen;
60
149
  }