@emaxe/tuigram 1.2.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.
@@ -2,6 +2,8 @@ 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";
6
+
5
7
  /**
6
8
  * Создаёт компонент поля ввода сообщения (нижняя панель).
7
9
  * @param {blessed.Widgets.Screen} screen
@@ -10,8 +12,16 @@ import { fg, badge } from "../theme.js";
10
12
  * @param {(text: string, context: { mode: string|null, target: object|null }) => void} callbacks.onSubmit
11
13
  * @param {() => void} [callbacks.onCancelContext]
12
14
  * @param {(command: string, args: string[]) => void} [callbacks.onSlashCommand]
15
+ * @param {() => void} [callbacks.onReplyLast]
16
+ * @param {() => void} [callbacks.onEditLast]
13
17
  */
14
- export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSlashCommand } = {}) {
18
+ export function createInputBox(screen, theme, {
19
+ onSubmit,
20
+ onCancelContext,
21
+ onSlashCommand,
22
+ onReplyLast,
23
+ onEditLast,
24
+ } = {}) {
15
25
  // Высота 5 = рамка (2) + контекстная плашка (1) + две строки ввода (2).
16
26
  // При autoPadding у blessed рамка съедает по строке сверху и снизу, поэтому
17
27
  // меньшая высота оставляет textarea нулевую высоту и вводимый текст не виден.
@@ -21,6 +31,7 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
21
31
  left: "35%",
22
32
  right: 0,
23
33
  height: 5,
34
+ mouse: true,
24
35
  border: {
25
36
  type: "line",
26
37
  },
@@ -41,6 +52,8 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
41
52
  right: 0,
42
53
  height: 1,
43
54
  tags: true,
55
+ mouse: true,
56
+ clickable: true,
44
57
  style: {
45
58
  bg: theme.input.contextBg,
46
59
  fg: theme.input.contextFg,
@@ -110,6 +123,32 @@ export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSla
110
123
  screen.render();
111
124
  }
112
125
 
126
+ contextBar.on("click", (data) => {
127
+ const relX = data.x - (contextBar.aleft || 0);
128
+ const action = getInputContextActionAt(relX, currentMode);
129
+ if (action === "cancel") {
130
+ if (currentMode) {
131
+ currentMode = null;
132
+ currentTarget = null;
133
+ renderContext();
134
+ onCancelContext?.();
135
+ }
136
+ } else if (action === "reply") {
137
+ onReplyLast?.();
138
+ } else if (action === "edit") {
139
+ onEditLast?.();
140
+ } else if (action === "commands") {
141
+ textarea.setValue("/");
142
+ textarea.focus();
143
+ screen.render();
144
+ }
145
+ });
146
+
147
+ textarea.on("click", () => {
148
+ textarea.focus();
149
+ screen.render();
150
+ });
151
+
113
152
  textarea.key(["enter"], () => {
114
153
  const value = textarea.getValue().trim();
115
154
  if (!value) {
@@ -2,6 +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";
6
+
5
7
  /**
6
8
  * Создаёт модальное окно контекстных действий над сообщением.
7
9
  * @param {blessed.Widgets.Screen} screen
@@ -19,6 +21,7 @@ export function createActionModal(screen, theme, { onAction } = {}) {
19
21
  height: "55%",
20
22
  hidden: true,
21
23
  tags: true,
24
+ mouse: true,
22
25
  border: {
23
26
  type: "line",
24
27
  },
@@ -66,6 +69,20 @@ export function createActionModal(screen, theme, { onAction } = {}) {
66
69
  },
67
70
  });
68
71
 
72
+ const baseCreateItem = list.createItem.bind(list);
73
+ list.createItem = (content) => {
74
+ const item = baseCreateItem(content);
75
+ item.on("click", () => {
76
+ const index = list.getItemIndex(item);
77
+ const action = currentActions[index];
78
+ if (action && currentMsg) {
79
+ hide();
80
+ onAction?.(action.id, currentMsg);
81
+ }
82
+ });
83
+ return item;
84
+ };
85
+
69
86
  let currentMsg = null;
70
87
  let currentActions = [];
71
88
  let previousFocus = null;
@@ -128,6 +145,20 @@ export function createActionModal(screen, theme, { onAction } = {}) {
128
145
  // Фокус получает список — на нём и живут клавиши закрытия.
129
146
  list.key(["escape", "q"], hide);
130
147
 
148
+ // Закрытие при клике мышью мимо модального окна
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
+ });
161
+
131
162
  return {
132
163
  modal,
133
164
  show,
@@ -3,6 +3,8 @@ 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";
7
+
6
8
  /**
7
9
  * Создаёт модальное окно информации о текущем чате.
8
10
  * @param {blessed.Widgets.Screen} screen
@@ -18,6 +20,7 @@ export function createChatInfoModal(screen, theme) {
18
20
  height: "60%",
19
21
  hidden: true,
20
22
  tags: true,
23
+ mouse: true,
21
24
  border: {
22
25
  type: "line",
23
26
  },
@@ -38,6 +41,7 @@ export function createChatInfoModal(screen, theme) {
38
41
  right: 2,
39
42
  bottom: 3,
40
43
  tags: true,
44
+ mouse: true,
41
45
  scrollable: true,
42
46
  style: {
43
47
  bg: theme.modal.bg,
@@ -114,9 +118,24 @@ export function createChatInfoModal(screen, theme) {
114
118
  }
115
119
 
116
120
  closeBtn.on("press", hide);
121
+ closeBtn.on("click", hide);
117
122
  // Клавиши вешаем на кнопку: blessed отдаёт события только сфокусированному элементу.
118
123
  closeBtn.key(["escape", "q"], hide);
119
124
 
125
+ // Закрытие при клике мышью мимо модального окна
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
+ });
138
+
120
139
  return {
121
140
  modal,
122
141
  show,
@@ -1,5 +1,6 @@
1
1
  import blessed from "neo-blessed";
2
2
  import { fg } from "../../theme.js";
3
+ import { isInsideBox } from "../../../utils/mouse.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,6 +129,20 @@ export function createConfirmModal(screen, theme) {
125
129
  });
126
130
  }
127
131
 
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
+ });
145
+
128
146
  return {
129
147
  modal,
130
148
  ask: (text, onConfirm) => {
@@ -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
  }