@emaxe/tuigram 1.1.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.
@@ -4,6 +4,8 @@ import { escapeBlessed } from "../../telegram/formatter.js";
4
4
  import { fg, badge } from "../theme.js";
5
5
  import unicode from "neo-blessed/lib/unicode.js";
6
6
 
7
+ import { getTabByCoordinate } from "../../utils/mouse.js";
8
+
7
9
  /**
8
10
  * Создаёт компонент списка диалогов (левая панель).
9
11
  * @param {blessed.Widgets.Screen} screen
@@ -20,6 +22,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
20
22
  left: 0,
21
23
  width: "35%",
22
24
  bottom: 1,
25
+ mouse: true,
23
26
  border: {
24
27
  type: "line",
25
28
  },
@@ -40,6 +43,8 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
40
43
  right: 0,
41
44
  height: 1,
42
45
  tags: true,
46
+ mouse: true,
47
+ clickable: true,
43
48
  style: {
44
49
  bg: theme.tabs.bg,
45
50
  fg: theme.tabs.fg,
@@ -64,6 +69,17 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
64
69
  tabsBox.setContent(rendered);
65
70
  }
66
71
 
72
+ tabsBox.on("click", (data) => {
73
+ const relX = data.x - (tabsBox.aleft || 0);
74
+ const tabKey = getTabByCoordinate(relX, TAB_KEYS, TAB_NAMES);
75
+ if (tabKey) {
76
+ currentTab = tabKey;
77
+ renderTabs();
78
+ onTabChange?.(currentTab);
79
+ screen.render();
80
+ }
81
+ });
82
+
67
83
  // 2. Строка поиска / фильтра
68
84
  const searchBox = blessed.textbox({
69
85
  parent: container,
@@ -72,6 +88,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
72
88
  right: 0,
73
89
  height: 1,
74
90
  inputOnFocus: true,
91
+ mouse: true,
75
92
  style: {
76
93
  bg: theme.search.bg,
77
94
  fg: theme.search.fg,
@@ -80,6 +97,14 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
80
97
  const SEARCH_PLACEHOLDER = "[/] Поиск чатов...";
81
98
  searchBox.setValue(SEARCH_PLACEHOLDER);
82
99
 
100
+ searchBox.on("click", () => {
101
+ if (searchBox.getValue() === SEARCH_PLACEHOLDER) {
102
+ searchBox.setValue("");
103
+ }
104
+ searchBox.focus();
105
+ screen.render();
106
+ });
107
+
83
108
  // 3. Список диалогов
84
109
  const list = blessed.list({
85
110
  parent: container,
@@ -118,13 +143,31 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
118
143
  // не влезающий хвост на вторую строку, которой в элементе высотой 1 просто
119
144
  // нет: хвост исчезал, а начатый перед разрывом фон бейджа непрочитанных
120
145
  // оставался залитым до края панели. С wrap: false лишнее просто обрезается.
146
+ // По клику мыши сразу выбираем и открываем диалог.
121
147
  const baseCreateItem = list.createItem.bind(list);
122
148
  list.createItem = (content) => {
123
149
  const item = baseCreateItem(content);
124
150
  item.wrap = false;
151
+ item.on("click", () => {
152
+ const index = list.getItemIndex(item);
153
+ if (index !== -1 && currentDialogs[index]) {
154
+ list.select(index);
155
+ onSelectDialog?.(currentDialogs[index]);
156
+ }
157
+ });
125
158
  return item;
126
159
  };
127
160
 
161
+ container.on("wheelup", () => {
162
+ list.select(list.selected - 2);
163
+ screen.render();
164
+ });
165
+
166
+ container.on("wheeldown", () => {
167
+ list.select(list.selected + 2);
168
+ screen.render();
169
+ });
170
+
128
171
  let currentDialogs = [];
129
172
 
130
173
  /**
@@ -275,6 +318,25 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
275
318
 
276
319
  renderTabs();
277
320
 
321
+ /** Подсвечивает рамку, когда список диалогов или строка поиска в фокусе. */
322
+ function setFocusHighlight(active) {
323
+ container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
324
+ screen.render();
325
+ }
326
+
327
+ list.on("focus", () => setFocusHighlight(true));
328
+ list.on("blur", (newTarget) => {
329
+ if (newTarget !== searchBox && screen.focused !== searchBox) {
330
+ setFocusHighlight(false);
331
+ }
332
+ });
333
+ searchBox.on("focus", () => setFocusHighlight(true));
334
+ searchBox.on("blur", (newTarget) => {
335
+ if (newTarget !== list && screen.focused !== list) {
336
+ setFocusHighlight(false);
337
+ }
338
+ });
339
+
278
340
  return {
279
341
  container,
280
342
  list,
@@ -3,6 +3,8 @@ import { formatMessageTime, formatDateDivider } from "../../utils/time.js";
3
3
  import { formatMessageText, escapeBlessed } from "../../telegram/formatter.js";
4
4
  import { fg } from "../theme.js";
5
5
 
6
+ import { getMessageAtLine } from "../../utils/mouse.js";
7
+
6
8
  /**
7
9
  * Создаёт компонент просмотра сообщений чата (правая центральная панель).
8
10
  * @param {blessed.Widgets.Screen} screen
@@ -18,6 +20,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
18
20
  left: "35%",
19
21
  right: 0,
20
22
  bottom: 6,
23
+ mouse: true,
21
24
  border: {
22
25
  type: "line",
23
26
  },
@@ -38,7 +41,6 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
38
41
  bottom: 0,
39
42
  tags: true,
40
43
  scrollable: true,
41
- alwaysScroll: true,
42
44
  mouse: true,
43
45
  keys: true,
44
46
  vi: true,
@@ -55,26 +57,43 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
55
57
  },
56
58
  });
57
59
 
60
+ /** Подсвечивает рамку, когда лента сообщений в фокусе. */
61
+ function setFocusHighlight(active) {
62
+ container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
63
+ screen.render();
64
+ }
65
+
66
+ scrollBox.on("focus", () => setFocusHighlight(true));
67
+ scrollBox.on("blur", () => setFocusHighlight(false));
68
+
58
69
  let currentMessages = [];
70
+ let currentRanges = [];
59
71
 
60
72
  /**
61
- * Форматирует список сообщений в единую ленту текста с разметкой Blessed.
73
+ * Форматирует список сообщений в единую ленту текста с разметкой Blessed
74
+ * и вычисляет координаты строк каждого сообщения для кликов мыши.
62
75
  * @param {Array<object>} messages
63
- * @returns {string}
76
+ * @returns {{ text: string, ranges: Array<{ message: object, startLine: number, endLine: number }> }}
64
77
  */
65
- function renderMessages(messages) {
78
+ function renderMessagesWithRanges(messages) {
66
79
  if (!messages || messages.length === 0) {
67
- return `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`;
80
+ return {
81
+ text: `\n\n ${fg(theme.muted, "Сообщений пока нет. Напишите первое сообщение ниже!")}`,
82
+ ranges: [],
83
+ };
68
84
  }
69
85
 
70
86
  let output = "";
71
87
  let lastDateString = "";
88
+ const ranges = [];
89
+ let lineCursor = 0;
72
90
 
73
91
  for (const msg of messages) {
74
92
  // Разделитель дат
75
93
  const dateStr = formatDateDivider(msg.date);
76
94
  if (dateStr && dateStr !== lastDateString) {
77
95
  output += `\n ${fg(theme.chatView.dateDivider, `─────── ${escapeBlessed(dateStr)} ───────`)}\n\n`;
96
+ lineCursor += 3;
78
97
  lastDateString = dateStr;
79
98
  }
80
99
 
@@ -93,14 +112,27 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
93
112
 
94
113
  // Блок ответа (Reply)
95
114
  let replyBlock = "";
115
+ let replyLines = 0;
96
116
  if (msg.replyToMsgId) {
97
117
  replyBlock = ` ${fg(theme.chatView.replyBorder, `┌─ Ответ на сообщение #${msg.replyToMsgId}`)}\n`;
118
+ replyLines = 1;
98
119
  }
99
120
 
100
121
  // Текст сообщения и entities
101
122
  let bodyText = formatMessageText(msg.text, msg.entities);
102
- if (msg.mediaDescription) {
103
- bodyText = bodyText ? `${msg.mediaDescription}\n ${bodyText}` : msg.mediaDescription;
123
+
124
+ // Блок медиа-вложения и превью изображения
125
+ let mediaBlock = "";
126
+ if (msg.imagePreview) {
127
+ mediaBlock = msg.mediaDescription
128
+ ? `${msg.mediaDescription}\n${msg.imagePreview}`
129
+ : msg.imagePreview;
130
+ } else if (msg.mediaDescription) {
131
+ mediaBlock = msg.mediaDescription;
132
+ }
133
+
134
+ if (mediaBlock) {
135
+ bodyText = bodyText ? `${mediaBlock}\n${bodyText}` : mediaBlock;
104
136
  }
105
137
 
106
138
  // Отступ строк текста сообщения
@@ -108,12 +140,15 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
108
140
  .split("\n")
109
141
  .map((line) => ` ${line}`)
110
142
  .join("\n");
143
+ const bodyLinesCount = indentedBody.split("\n").length;
111
144
 
112
145
  // Реакции
113
146
  let reactionsLine = "";
147
+ let reactionLinesCount = 0;
114
148
  if (msg.reactions && msg.reactions.length > 0) {
115
149
  const list = msg.reactions.map((r) => `${r.emoticon} ${r.count}`).join(" ");
116
150
  reactionsLine = `\n ${fg(theme.chatView.reactionFg, `{bold}${list}{/bold}`)}`;
151
+ reactionLinesCount = 1;
117
152
  }
118
153
 
119
154
  // Метка редактирования
@@ -122,10 +157,17 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
122
157
  editedTag = ` ${fg(theme.chatView.time, "(изменено)")}`;
123
158
  }
124
159
 
160
+ const startLine = lineCursor;
161
+ const totalMsgLines = 1 + replyLines + bodyLinesCount + reactionLinesCount;
162
+ const endLine = startLine + totalMsgLines - 1;
163
+
164
+ ranges.push({ message: msg, startLine, endLine });
165
+ lineCursor += totalMsgLines + 2;
166
+
125
167
  output += ` ${authorTag}${editedTag}\n${replyBlock}${indentedBody}${reactionsLine}\n\n`;
126
168
  }
127
169
 
128
- return output;
170
+ return { text: output, ranges };
129
171
  }
130
172
 
131
173
  /**
@@ -135,25 +177,84 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
135
177
  */
136
178
  function setMessages(messages, autoScrollToBottom = true) {
137
179
  currentMessages = messages;
138
- scrollBox.setContent(renderMessages(messages));
180
+ const prevScroll = scrollBox.getScroll();
181
+ const prevHeight = scrollBox.getScrollHeight();
182
+
183
+ const rendered = renderMessagesWithRanges(messages);
184
+ currentRanges = rendered.ranges;
185
+ scrollBox.setContent(rendered.text);
186
+
139
187
  if (autoScrollToBottom) {
140
188
  scrollBox.setScrollPerc(100);
189
+ } else {
190
+ // Сохраняем относительную позицию скролла: если добавились старые сообщения сверху,
191
+ // компенсируем сдвиг высоты ленты
192
+ const newHeight = scrollBox.getScrollHeight();
193
+ const addedLines = newHeight - prevHeight;
194
+ if (addedLines > 0 && prevScroll > 0) {
195
+ scrollBox.scrollTo(prevScroll + addedLines);
196
+ } else if (prevScroll > 0) {
197
+ scrollBox.scrollTo(prevScroll);
198
+ }
141
199
  }
142
200
  screen.render();
143
201
  }
144
202
 
145
203
  // Обработка прокрутки вверх для подгрузки истории
146
- scrollBox.key(["pageup", "C-u"], () => {
147
- scrollBox.scroll(-10);
204
+ function handleScrollUp(step = 10) {
205
+ scrollBox.scroll(-step);
148
206
  if (scrollBox.getScroll() <= 0) {
149
207
  onLoadMoreHistory?.();
150
208
  }
151
209
  screen.render();
152
- });
210
+ }
153
211
 
154
- scrollBox.key(["pagedown", "C-d"], () => {
155
- scrollBox.scroll(10);
212
+ function handleScrollDown(step = 10) {
213
+ scrollBox.scroll(step);
156
214
  screen.render();
215
+ }
216
+
217
+ scrollBox.key(["pageup", "C-u"], () => handleScrollUp(10));
218
+ scrollBox.key(["pagedown", "C-d"], () => handleScrollDown(10));
219
+ scrollBox.key(["up", "k"], () => handleScrollUp(2));
220
+ scrollBox.key(["down", "j"], () => handleScrollDown(2));
221
+ scrollBox.key(["home"], () => {
222
+ scrollBox.scrollTo(0);
223
+ onLoadMoreHistory?.();
224
+ screen.render();
225
+ });
226
+ scrollBox.key(["end"], () => {
227
+ scrollBox.setScrollPerc(100);
228
+ screen.render();
229
+ });
230
+
231
+ scrollBox.on("wheelup", () => {
232
+ handleScrollUp(3);
233
+ });
234
+
235
+ scrollBox.on("wheeldown", () => {
236
+ handleScrollDown(3);
237
+ });
238
+
239
+ container.on("wheelup", () => {
240
+ handleScrollUp(3);
241
+ });
242
+
243
+ container.on("wheeldown", () => {
244
+ handleScrollDown(3);
245
+ });
246
+
247
+ scrollBox.on("click", (data) => {
248
+ const clickY = data.y;
249
+ const itop = scrollBox.itop || 0;
250
+ const lineIndex = clickY - (scrollBox.atop || 0) + scrollBox.getScroll() - itop;
251
+ const clickedMsg = getMessageAtLine(lineIndex, currentRanges);
252
+ if (clickedMsg) {
253
+ onActionMenu?.(clickedMsg);
254
+ } else {
255
+ scrollBox.focus();
256
+ screen.render();
257
+ }
157
258
  });
158
259
 
159
260
  // Ctrl+M терминал шлёт как "\r" (имя клавиши "return"), поэтому меню действий
@@ -169,6 +270,7 @@ export function createChatView(screen, theme, { onLoadMoreHistory, onActionMenu
169
270
  container,
170
271
  scrollBox,
171
272
  setMessages,
273
+ loadMore: () => onLoadMoreHistory?.(),
172
274
  scrollToBottom: () => {
173
275
  scrollBox.setScrollPerc(100);
174
276
  screen.render();
@@ -2,13 +2,21 @@ 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";
6
+
5
7
  /**
6
8
  * Верхняя шапка приложения с информацией о пользователе, активном чате и статусе соединения.
7
9
  * @param {blessed.Widgets.Screen} screen
8
10
  * @param {object} theme
11
+ * @param {object} [callbacks]
12
+ * @param {() => void} [callbacks.onHelp]
13
+ * @param {() => void} [callbacks.onChatInfo]
14
+ * @param {() => void} [callbacks.onStatusClick]
9
15
  * @returns {blessed.Widgets.BoxElement & { updateInfo: (data: object) => void }}
10
16
  */
11
- export function createHeader(screen, theme) {
17
+ export function createHeader(screen, theme, { onHelp, onChatInfo, onStatusClick } = {}) {
18
+ let currentActiveChat = null;
19
+
12
20
  const headerBox = blessed.box({
13
21
  parent: screen,
14
22
  top: 0,
@@ -17,6 +25,7 @@ export function createHeader(screen, theme) {
17
25
  // 4 = рамка (2) + две строки контента (профиль + активный чат)
18
26
  height: 4,
19
27
  tags: true,
28
+ mouse: true,
20
29
  border: {
21
30
  type: "line",
22
31
  },
@@ -29,6 +38,19 @@ export function createHeader(screen, theme) {
29
38
  },
30
39
  });
31
40
 
41
+ headerBox.on("click", (data) => {
42
+ const relX = data.x - (headerBox.aleft || 0);
43
+ const relY = data.y - (headerBox.atop || 0);
44
+ const action = getHeaderActionAt(relX, relY, { hasActiveChat: Boolean(currentActiveChat) });
45
+ if (action === "help") {
46
+ onHelp?.();
47
+ } else if (action === "info") {
48
+ onChatInfo?.();
49
+ } else if (action === "status") {
50
+ onStatusClick?.();
51
+ }
52
+ });
53
+
32
54
  /**
33
55
  * Обновляет содержимое шапки.
34
56
  * @param {object} data
@@ -38,6 +60,7 @@ export function createHeader(screen, theme) {
38
60
  * @param {string|null} [data.typingUser]
39
61
  */
40
62
  headerBox.updateInfo = function ({ me, status = "connected", activeChat, typingUser }) {
63
+ currentActiveChat = activeChat;
41
64
  let statusBadge = fg(theme.status.online, "● В сети");
42
65
  if (status === "connecting") {
43
66
  statusBadge = fg(theme.status.connecting, "◌ Подключение...");
@@ -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) => {