@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.
package/src/ui/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
  import fs from "node:fs";
3
- import { createScreen } from "./screen.js";
3
+ import { createScreen, setMouseCapture } from "./screen.js";
4
4
  import { getTheme } from "./theme.js";
5
5
  import { createHeader } from "./components/header.js";
6
6
  import { createChatList } from "./components/chatList.js";
@@ -12,16 +12,18 @@ import { createChatInfoModal } from "./components/modals/chatInfoModal.js";
12
12
  import { createActionModal } from "./components/modals/actionModal.js";
13
13
  import { createFileModal } from "./components/modals/fileModal.js";
14
14
  import { createConfirmModal } from "./components/modals/confirmModal.js";
15
+ import { createImageViewerModal } from "./components/modals/imageViewerModal.js";
15
16
  import { state } from "../state.js";
16
17
  import { config } from "../config.js";
17
18
  import { fetchDialogs } from "../telegram/dialogs.js";
18
19
  import { setMessagePalette } from "../telegram/formatter.js";
19
- import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview } from "../telegram/messages.js";
20
+ import { fetchHistory, sendMessage, editMessage, deleteMessages, sendFiles, downloadMedia, sendReaction, markAsRead, loadMessageImagePreview, downloadImageBuffer, renderMessageThumbnail } from "../telegram/messages.js";
20
21
  import { startTelegramListener } from "../telegram/listener.js";
21
22
  import { logout } from "../telegram/auth.js";
22
23
  import { ensureDir, inspectLocalFile } from "../utils/storage.js";
23
24
  import { formatFileSize } from "../utils/time.js";
24
25
  import { parseSendFileArgs } from "../utils/commands.js";
26
+ import { isRightClick } from "../utils/mouse.js";
25
27
 
26
28
  /**
27
29
  * Запускает полноэкранный TUI-клиент Telegram.
@@ -43,8 +45,79 @@ export async function startTui(client, me) {
43
45
  state.setConnectionStatus("connected");
44
46
 
45
47
  // 1. Создание визуальных компонентов
46
- const header = createHeader(screen, theme);
47
- const statusBar = createStatusBar(screen, theme);
48
+ const header = createHeader(screen, theme, {
49
+ onHelp: () => {
50
+ releaseInputs();
51
+ helpModal.show();
52
+ },
53
+ onChatInfo: () => {
54
+ if (state.activeChat) {
55
+ releaseInputs();
56
+ chatInfoModal.show(state.activeChat);
57
+ } else {
58
+ statusBar.showMessage("Сначала выберите чат слева!", "warning");
59
+ }
60
+ },
61
+ onStatusClick: () => {
62
+ const statusText = state.connectionStatus === "connected"
63
+ ? "Подключение активно: MTProto онлайн"
64
+ : `Статус сети: ${state.connectionStatus}`;
65
+ statusBar.showMessage(statusText, "info");
66
+ },
67
+ });
68
+
69
+ const statusBar = createStatusBar(screen, theme, {
70
+ onFocusNext: () => moveFocus(1),
71
+ onSelectOrSubmit: () => {
72
+ const focused = detectFocus();
73
+ if (focused === "input") {
74
+ inputBox.textarea.emit("key enter");
75
+ } else if (focused === "chatList") {
76
+ chatList.list.emit("key enter");
77
+ }
78
+ },
79
+ onFilterTabs: () => {
80
+ releaseInputs();
81
+ chatList.focus();
82
+ statusBar.showMessage("Фильтры: нажмите 1-6 для выбора вкладки", "info");
83
+ },
84
+ onSearch: () => {
85
+ chatList.searchBox.setValue("");
86
+ chatList.searchBox.focus();
87
+ screen.render();
88
+ },
89
+ onHelp: () => {
90
+ releaseInputs();
91
+ helpModal.show();
92
+ },
93
+ onAction: () => {
94
+ if (!state.activeChat) {
95
+ statusBar.showMessage("Сначала выберите чат слева!", "warning");
96
+ return;
97
+ }
98
+ const msg = chatView.getTargetMessage();
99
+ if (msg) {
100
+ releaseInputs();
101
+ actionModal.show(msg);
102
+ }
103
+ },
104
+ onChatInfo: () => {
105
+ if (state.activeChat) {
106
+ releaseInputs();
107
+ chatInfoModal.show(state.activeChat);
108
+ } else {
109
+ statusBar.showMessage("Сначала выберите чат слева!", "warning");
110
+ }
111
+ },
112
+ onQuit: () => {
113
+ releaseInputs();
114
+ confirmModal.ask("Выйти из TuiGram?", () => {
115
+ listener?.stop();
116
+ screen.destroy();
117
+ process.exit(0);
118
+ });
119
+ },
120
+ });
48
121
 
49
122
  // Модальные окна
50
123
  const helpModal = createHelpModal(screen, theme);
@@ -54,6 +127,16 @@ export async function startTui(client, me) {
54
127
  onSendFile: (files, options) => sendFilesToActiveChat(files, options),
55
128
  });
56
129
 
130
+ const imageViewerModal = createImageViewerModal(screen, theme, {
131
+ onLoadFullImage: (msg) => downloadImageBuffer(client, msg.rawMessage),
132
+ // Пока качается оригинал, показываем встроенную в сообщение миниатюру
133
+ onRenderPlaceholder: (msg, size) => renderMessageThumbnail(msg.rawMessage, {
134
+ maxWidth: size.maxWidth,
135
+ maxHeight: size.maxHeight,
136
+ useCache: false,
137
+ }),
138
+ });
139
+
57
140
  const actionModal = createActionModal(screen, theme, {
58
141
  onAction: async (actionId, msg) => {
59
142
  if (!state.activeChat) return;
@@ -168,6 +251,19 @@ export async function startTui(client, me) {
168
251
  releaseInputs();
169
252
  actionModal.show(msg);
170
253
  },
254
+ onSelectMessage: (msg) => {
255
+ const preview = (msg.text || msg.mediaDescription || "вложение").replace(/\s+/g, " ").slice(0, 30);
256
+ statusBar.showMessage(
257
+ `Выделено #${msg.id}: "${preview}" · [Enter] или правый клик — действия`,
258
+ "info",
259
+ 3000
260
+ );
261
+ },
262
+ onOpenImage: (msg) => {
263
+ releaseInputs();
264
+ imageViewerModal.show(msg);
265
+ },
266
+ onFocusRequest: () => releaseInputs(),
171
267
  });
172
268
 
173
269
  // 4. Поле ввода (нижняя панель)
@@ -205,6 +301,8 @@ export async function startTui(client, me) {
205
301
  onCancelContext: () => {
206
302
  statusBar.showMessage("Режим ответа/редактирования сброшен", "info", 2000);
207
303
  },
304
+ onReplyLast: () => startReply(),
305
+ onEditLast: () => startEdit(),
208
306
  onSlashCommand: (cmd, args) => {
209
307
  switch (cmd) {
210
308
  case "help":
@@ -269,6 +367,36 @@ export async function startTui(client, me) {
269
367
  },
270
368
  });
271
369
 
370
+ // Переключение фокуса по клику мышью на любую из трех панелей
371
+ chatList.container.on("click", (data) => {
372
+ if (isRightClick(data)) return;
373
+ if (screen.focused !== chatList.list && screen.focused !== chatList.searchBox) {
374
+ releaseInputs();
375
+ chatList.focus();
376
+ statusBar.showMessage("Фокус: список чатов", "info", 2000);
377
+ screen.render();
378
+ }
379
+ });
380
+
381
+ chatView.container.on("click", (data) => {
382
+ if (isRightClick(data)) return;
383
+ if (screen.focused !== chatView.scrollBox) {
384
+ releaseInputs();
385
+ chatView.focus();
386
+ statusBar.showMessage("Фокус: лента сообщений", "info", 2000);
387
+ screen.render();
388
+ }
389
+ });
390
+
391
+ inputBox.container.on("click", (data) => {
392
+ if (isRightClick(data)) return;
393
+ if (screen.focused !== inputBox.textarea) {
394
+ inputBox.focus();
395
+ statusBar.showMessage("Фокус: поле ввода", "info", 2000);
396
+ screen.render();
397
+ }
398
+ });
399
+
272
400
  // 5. Реакция на события состояния
273
401
  state.on("dialogs_updated", (dialogs) => {
274
402
  chatList.setDialogs(dialogs);
@@ -290,6 +418,7 @@ export async function startTui(client, me) {
290
418
  typingUser: state.getTypingUser(chat?.id),
291
419
  });
292
420
  const msgs = chat ? state.getMessages(chat.id) : [];
421
+ chatView.setSelected(null);
293
422
  chatView.setMessages(msgs);
294
423
  });
295
424
 
@@ -459,6 +588,31 @@ export async function startTui(client, me) {
459
588
  chatList.release?.();
460
589
  }
461
590
 
591
+ /** Включает режим ответа на выделенное сообщение, иначе — на последнее в ленте. */
592
+ function startReply() {
593
+ if (!state.activeChat) return;
594
+ const target = chatView.getTargetMessage();
595
+ if (target) {
596
+ inputBox.setContext("reply", target);
597
+ }
598
+ }
599
+
600
+ /** Включает правку выделенного своего сообщения, иначе — последнего своего. */
601
+ function startEdit() {
602
+ if (!state.activeChat) return;
603
+ const selected = chatView.getSelected();
604
+ if (selected?.out) {
605
+ inputBox.setContext("edit", selected);
606
+ return;
607
+ }
608
+ const ownMsgs = state.getMessages(state.activeChat.id).filter((m) => m.out);
609
+ if (ownMsgs.length > 0) {
610
+ inputBox.setContext("edit", ownMsgs[ownMsgs.length - 1]);
611
+ } else {
612
+ statusBar.showMessage("В этом чате нет ваших сообщений для правки", "warning", 3000);
613
+ }
614
+ }
615
+
462
616
  // 7. Глобальные сочетания клавиш
463
617
  // Цикл фокуса: список чатов -> лента сообщений -> поле ввода -> список чатов.
464
618
  // Без ленты в цикле были недостижимы прокрутка, подгрузка истории и меню действий.
@@ -501,17 +655,18 @@ export async function startTui(client, me) {
501
655
  screen.key(["tab"], () => moveFocus(1));
502
656
  screen.key(["S-tab"], () => moveFocus(-1));
503
657
 
658
+ // Когда лента в фокусе, эти клавиши обрабатывает она сама — иначе прокрутка удваивается
504
659
  screen.key(["pageup", "C-u"], () => {
505
- if (!state.activeChat) return;
660
+ if (!state.activeChat || screen.focused === chatView.scrollBox) return;
506
661
  chatView.scrollBox.scroll(-10);
507
- if (chatView.scrollBox.getScroll() <= 0) {
662
+ if ((chatView.scrollBox.childBase || 0) <= 0) {
508
663
  chatView.loadMore?.();
509
664
  }
510
665
  screen.render();
511
666
  });
512
667
 
513
668
  screen.key(["pagedown", "C-d"], () => {
514
- if (!state.activeChat) return;
669
+ if (!state.activeChat || screen.focused === chatView.scrollBox) return;
515
670
  chatView.scrollBox.scroll(10);
516
671
  screen.render();
517
672
  });
@@ -540,23 +695,36 @@ export async function startTui(client, me) {
540
695
  }
541
696
  });
542
697
 
543
- screen.key(["C-r"], () => {
544
- if (!state.activeChat) return;
545
- const msgs = state.getMessages(state.activeChat.id);
546
- if (msgs.length > 0) {
547
- const lastMsg = msgs[msgs.length - 1];
548
- inputBox.setContext("reply", lastMsg);
698
+ screen.key(["C-r"], () => startReply());
699
+
700
+ screen.key(["C-e"], () => startEdit());
701
+
702
+ // Меню действий над выделенным сообщением из любой панели.
703
+ // Когда лента в фокусе, Ctrl+A обрабатывает она сама.
704
+ screen.key(["C-a"], () => {
705
+ if (screen.focused === chatView.scrollBox) return;
706
+ if (!state.activeChat) {
707
+ statusBar.showMessage("Сначала выберите чат слева!", "warning");
708
+ return;
709
+ }
710
+ const msg = chatView.getTargetMessage();
711
+ if (msg) {
712
+ releaseInputs();
713
+ actionModal.show(msg);
549
714
  }
550
715
  });
551
716
 
552
- screen.key(["C-e"], () => {
553
- if (!state.activeChat) return;
554
- const msgs = state.getMessages(state.activeChat.id);
555
- const ownMsgs = msgs.filter((m) => m.out);
556
- if (ownMsgs.length > 0) {
557
- const lastOwn = ownMsgs[ownMsgs.length - 1];
558
- inputBox.setContext("edit", lastOwn);
559
- }
717
+ // Пока мышь захвачена приложением, терминал не даёт выделять текст для копирования
718
+ screen.key(["f12"], () => {
719
+ const enabled = !screen.mouseCaptured;
720
+ setMouseCapture(screen, enabled);
721
+ statusBar.showMessage(
722
+ enabled
723
+ ? "Мышь снова управляет интерфейсом"
724
+ : "Мышь отдана терминалу — можно выделять и копировать текст. [F12] вернуть",
725
+ enabled ? "info" : "warning",
726
+ 5000
727
+ );
560
728
  });
561
729
 
562
730
  screen.key(["C-q"], () => {
@@ -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, isRightClick } 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,18 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
64
69
  tabsBox.setContent(rendered);
65
70
  }
66
71
 
72
+ tabsBox.on("click", (data) => {
73
+ if (isRightClick(data)) return;
74
+ const relX = data.x - (tabsBox.aleft || 0);
75
+ const tabKey = getTabByCoordinate(relX, TAB_KEYS, TAB_NAMES);
76
+ if (tabKey) {
77
+ currentTab = tabKey;
78
+ renderTabs();
79
+ onTabChange?.(currentTab);
80
+ screen.render();
81
+ }
82
+ });
83
+
67
84
  // 2. Строка поиска / фильтра
68
85
  const searchBox = blessed.textbox({
69
86
  parent: container,
@@ -72,6 +89,7 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
72
89
  right: 0,
73
90
  height: 1,
74
91
  inputOnFocus: true,
92
+ mouse: true,
75
93
  style: {
76
94
  bg: theme.search.bg,
77
95
  fg: theme.search.fg,
@@ -80,6 +98,14 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
80
98
  const SEARCH_PLACEHOLDER = "[/] Поиск чатов...";
81
99
  searchBox.setValue(SEARCH_PLACEHOLDER);
82
100
 
101
+ searchBox.on("click", () => {
102
+ if (searchBox.getValue() === SEARCH_PLACEHOLDER) {
103
+ searchBox.setValue("");
104
+ }
105
+ searchBox.focus();
106
+ screen.render();
107
+ });
108
+
83
109
  // 3. Список диалогов
84
110
  const list = blessed.list({
85
111
  parent: container,
@@ -118,13 +144,32 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
118
144
  // не влезающий хвост на вторую строку, которой в элементе высотой 1 просто
119
145
  // нет: хвост исчезал, а начатый перед разрывом фон бейджа непрочитанных
120
146
  // оставался залитым до края панели. С wrap: false лишнее просто обрезается.
147
+ // По клику мыши сразу выбираем и открываем диалог.
121
148
  const baseCreateItem = list.createItem.bind(list);
122
149
  list.createItem = (content) => {
123
150
  const item = baseCreateItem(content);
124
151
  item.wrap = false;
152
+ item.on("click", (data) => {
153
+ if (isRightClick(data)) return;
154
+ const index = list.getItemIndex(item);
155
+ if (index !== -1 && currentDialogs[index]) {
156
+ list.select(index);
157
+ onSelectDialog?.(currentDialogs[index]);
158
+ }
159
+ });
125
160
  return item;
126
161
  };
127
162
 
163
+ container.on("wheelup", () => {
164
+ list.select(list.selected - 2);
165
+ screen.render();
166
+ });
167
+
168
+ container.on("wheeldown", () => {
169
+ list.select(list.selected + 2);
170
+ screen.render();
171
+ });
172
+
128
173
  let currentDialogs = [];
129
174
 
130
175
  /**
@@ -275,6 +320,25 @@ export function createChatList(screen, theme, { onSelectDialog, onTabChange, onS
275
320
 
276
321
  renderTabs();
277
322
 
323
+ /** Подсвечивает рамку, когда список диалогов или строка поиска в фокусе. */
324
+ function setFocusHighlight(active) {
325
+ container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
326
+ screen.render();
327
+ }
328
+
329
+ list.on("focus", () => setFocusHighlight(true));
330
+ list.on("blur", (newTarget) => {
331
+ if (newTarget !== searchBox && screen.focused !== searchBox) {
332
+ setFocusHighlight(false);
333
+ }
334
+ });
335
+ searchBox.on("focus", () => setFocusHighlight(true));
336
+ searchBox.on("blur", (newTarget) => {
337
+ if (newTarget !== list && screen.focused !== list) {
338
+ setFocusHighlight(false);
339
+ }
340
+ });
341
+
278
342
  return {
279
343
  container,
280
344
  list,