@emaxe/tuigram 1.0.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/.env.example +29 -0
- package/LICENSE +21 -0
- package/README.md +364 -0
- package/bin/tuigram.js +2 -0
- package/package.json +73 -0
- package/src/cli/cliCommands.js +164 -0
- package/src/cli/formatters.js +72 -0
- package/src/cli/init.js +197 -0
- package/src/config.js +158 -0
- package/src/index.js +172 -0
- package/src/state.js +243 -0
- package/src/telegram/auth.js +146 -0
- package/src/telegram/client.js +96 -0
- package/src/telegram/dialogs.js +130 -0
- package/src/telegram/entities.js +158 -0
- package/src/telegram/formatter.js +248 -0
- package/src/telegram/listener.js +141 -0
- package/src/telegram/messages.js +265 -0
- package/src/ui/app.js +534 -0
- package/src/ui/components/chatList.js +295 -0
- package/src/ui/components/chatView.js +178 -0
- package/src/ui/components/header.js +82 -0
- package/src/ui/components/inputBox.js +221 -0
- package/src/ui/components/modals/actionModal.js +136 -0
- package/src/ui/components/modals/chatInfoModal.js +125 -0
- package/src/ui/components/modals/confirmModal.js +141 -0
- package/src/ui/components/modals/fileModal.js +355 -0
- package/src/ui/components/modals/filePickerModal.js +214 -0
- package/src/ui/components/modals/helpModal.js +131 -0
- package/src/ui/components/statusBar.js +64 -0
- package/src/ui/screen.js +60 -0
- package/src/ui/theme.js +223 -0
- package/src/utils/commands.js +40 -0
- package/src/utils/storage.js +177 -0
- package/src/utils/time.js +132 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import blessed from "neo-blessed";
|
|
2
|
+
import { escapeBlessed } from "../../telegram/formatter.js";
|
|
3
|
+
import { fg, badge } from "../theme.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Создаёт компонент поля ввода сообщения (нижняя панель).
|
|
7
|
+
* @param {blessed.Widgets.Screen} screen
|
|
8
|
+
* @param {object} theme
|
|
9
|
+
* @param {object} callbacks
|
|
10
|
+
* @param {(text: string, context: { mode: string|null, target: object|null }) => void} callbacks.onSubmit
|
|
11
|
+
* @param {() => void} [callbacks.onCancelContext]
|
|
12
|
+
* @param {(command: string, args: string[]) => void} [callbacks.onSlashCommand]
|
|
13
|
+
*/
|
|
14
|
+
export function createInputBox(screen, theme, { onSubmit, onCancelContext, onSlashCommand } = {}) {
|
|
15
|
+
// Высота 5 = рамка (2) + контекстная плашка (1) + две строки ввода (2).
|
|
16
|
+
// При autoPadding у blessed рамка съедает по строке сверху и снизу, поэтому
|
|
17
|
+
// меньшая высота оставляет textarea нулевую высоту и вводимый текст не виден.
|
|
18
|
+
const container = blessed.box({
|
|
19
|
+
parent: screen,
|
|
20
|
+
bottom: 1,
|
|
21
|
+
left: "35%",
|
|
22
|
+
right: 0,
|
|
23
|
+
height: 5,
|
|
24
|
+
border: {
|
|
25
|
+
type: "line",
|
|
26
|
+
},
|
|
27
|
+
style: {
|
|
28
|
+
bg: theme.input.bg,
|
|
29
|
+
fg: theme.input.fg,
|
|
30
|
+
border: {
|
|
31
|
+
fg: theme.borders.fg,
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// 1. Контекстная плашка (Ответ / Редактирование / Подсказка)
|
|
37
|
+
const contextBar = blessed.box({
|
|
38
|
+
parent: container,
|
|
39
|
+
top: 0,
|
|
40
|
+
left: 0,
|
|
41
|
+
right: 0,
|
|
42
|
+
height: 1,
|
|
43
|
+
tags: true,
|
|
44
|
+
style: {
|
|
45
|
+
bg: theme.input.contextBg,
|
|
46
|
+
fg: theme.input.contextFg,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// 2. Поле ввода текста
|
|
51
|
+
const textarea = blessed.textarea({
|
|
52
|
+
parent: container,
|
|
53
|
+
top: 1,
|
|
54
|
+
left: 0,
|
|
55
|
+
right: 0,
|
|
56
|
+
bottom: 0,
|
|
57
|
+
inputOnFocus: true,
|
|
58
|
+
scrollable: true,
|
|
59
|
+
// keys: false намеренно: при keys:true blessed перехватывает Ctrl+E внутри
|
|
60
|
+
// textarea и запускает внешний $EDITOR, ломая TUI. Ввод работает через
|
|
61
|
+
// inputOnFocus, поэтому эта опция здесь не нужна.
|
|
62
|
+
keys: false,
|
|
63
|
+
mouse: true,
|
|
64
|
+
style: {
|
|
65
|
+
bg: theme.input.bg,
|
|
66
|
+
fg: theme.input.fg,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/** Подсвечивает рамку, когда ввод в фокусе — иначе непонятно, куда идут символы. */
|
|
71
|
+
function setFocusHighlight(active) {
|
|
72
|
+
container.style.border.fg = active ? theme.borders.focusFg : theme.borders.fg;
|
|
73
|
+
screen.render();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
textarea.on("focus", () => setFocusHighlight(true));
|
|
77
|
+
textarea.on("blur", () => setFocusHighlight(false));
|
|
78
|
+
|
|
79
|
+
let currentMode = null; // null | "reply" | "edit"
|
|
80
|
+
let currentTarget = null; // object message
|
|
81
|
+
|
|
82
|
+
const history = [];
|
|
83
|
+
let historyIndex = -1;
|
|
84
|
+
|
|
85
|
+
function renderContext() {
|
|
86
|
+
if (currentMode === "reply" && currentTarget) {
|
|
87
|
+
const author = escapeBlessed(currentTarget.senderName || "Собеседник");
|
|
88
|
+
const preview = escapeBlessed((currentTarget.text || "").slice(0, 30));
|
|
89
|
+
contextBar.setContent(
|
|
90
|
+
badge(theme.input.replyBg, theme.input.replyFg,
|
|
91
|
+
`{bold} ↩️ Ответ на [${author}]: "${preview}..." {/bold}[Esc: Отмена] `)
|
|
92
|
+
);
|
|
93
|
+
} else if (currentMode === "edit" && currentTarget) {
|
|
94
|
+
const preview = escapeBlessed((currentTarget.text || "").slice(0, 30));
|
|
95
|
+
contextBar.setContent(
|
|
96
|
+
badge(theme.input.editBg, theme.input.editFg,
|
|
97
|
+
`{bold} ✏️ Редактирование #${currentTarget.id}: "${preview}..." {/bold}[Esc: Отмена] `)
|
|
98
|
+
);
|
|
99
|
+
} else {
|
|
100
|
+
const key = (label) => fg(theme.accent, label);
|
|
101
|
+
contextBar.setContent(
|
|
102
|
+
` ${fg(theme.input.contextFg, "Введите сообщение...")} ` +
|
|
103
|
+
`${key("[Enter]")} ${fg(theme.input.contextFg, "Отправить")} ` +
|
|
104
|
+
`${key("[Ctrl+J]")} ${fg(theme.input.contextFg, "Новая строка")} ` +
|
|
105
|
+
`${key("[Ctrl+R]")} ${fg(theme.input.contextFg, "Ответ")} ` +
|
|
106
|
+
`${key("[Ctrl+E]")} ${fg(theme.input.contextFg, "Правка")} ` +
|
|
107
|
+
`${key("[/]")} ${fg(theme.input.contextFg, "Команды")}`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
screen.render();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
textarea.key(["enter"], () => {
|
|
114
|
+
const value = textarea.getValue().trim();
|
|
115
|
+
if (!value) {
|
|
116
|
+
// Textarea уже успел дописать перевод строки в своём обработчике — убираем его.
|
|
117
|
+
textarea.setValue("");
|
|
118
|
+
screen.render();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Обработка слэш-команд
|
|
123
|
+
if (value.startsWith("/")) {
|
|
124
|
+
const [cmd, ...args] = value.slice(1).split(" ");
|
|
125
|
+
textarea.setValue("");
|
|
126
|
+
history.push(value);
|
|
127
|
+
historyIndex = -1;
|
|
128
|
+
onSlashCommand?.(cmd.toLowerCase(), args);
|
|
129
|
+
screen.render();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
history.push(value);
|
|
134
|
+
historyIndex = -1;
|
|
135
|
+
textarea.setValue("");
|
|
136
|
+
|
|
137
|
+
const ctx = { mode: currentMode, target: currentTarget };
|
|
138
|
+
currentMode = null;
|
|
139
|
+
currentTarget = null;
|
|
140
|
+
renderContext();
|
|
141
|
+
|
|
142
|
+
onSubmit?.(value, ctx);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// Перенос строки без отправки: Ctrl+J приходит как "linefeed" и вставляется
|
|
146
|
+
// самим textarea, поэтому отдельный обработчик не нужен (иначе будет двойной \n).
|
|
147
|
+
|
|
148
|
+
textarea.key(["escape"], () => {
|
|
149
|
+
if (currentMode) {
|
|
150
|
+
currentMode = null;
|
|
151
|
+
currentTarget = null;
|
|
152
|
+
renderContext();
|
|
153
|
+
onCancelContext?.();
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
textarea.key(["up"], () => {
|
|
158
|
+
if (history.length === 0) return;
|
|
159
|
+
if (historyIndex === -1) {
|
|
160
|
+
historyIndex = history.length - 1;
|
|
161
|
+
} else if (historyIndex > 0) {
|
|
162
|
+
historyIndex--;
|
|
163
|
+
}
|
|
164
|
+
textarea.setValue(history[historyIndex]);
|
|
165
|
+
screen.render();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
textarea.key(["down"], () => {
|
|
169
|
+
if (historyIndex === -1) return;
|
|
170
|
+
if (historyIndex < history.length - 1) {
|
|
171
|
+
historyIndex++;
|
|
172
|
+
textarea.setValue(history[historyIndex]);
|
|
173
|
+
} else {
|
|
174
|
+
historyIndex = -1;
|
|
175
|
+
textarea.setValue("");
|
|
176
|
+
}
|
|
177
|
+
screen.render();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
renderContext();
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
container,
|
|
184
|
+
textarea,
|
|
185
|
+
contextBar,
|
|
186
|
+
setContext: (mode, target) => {
|
|
187
|
+
currentMode = mode;
|
|
188
|
+
currentTarget = target;
|
|
189
|
+
if (mode === "edit" && target?.text) {
|
|
190
|
+
textarea.setValue(target.text);
|
|
191
|
+
}
|
|
192
|
+
renderContext();
|
|
193
|
+
textarea.focus();
|
|
194
|
+
},
|
|
195
|
+
/**
|
|
196
|
+
* Текущий режим ввода — нужен, чтобы отправить файл ответом.
|
|
197
|
+
* @returns {{ mode: string|null, target: object|null }}
|
|
198
|
+
*/
|
|
199
|
+
getContext: () => ({ mode: currentMode, target: currentTarget }),
|
|
200
|
+
clearContext: () => {
|
|
201
|
+
currentMode = null;
|
|
202
|
+
currentTarget = null;
|
|
203
|
+
renderContext();
|
|
204
|
+
},
|
|
205
|
+
clear: () => {
|
|
206
|
+
textarea.setValue("");
|
|
207
|
+
screen.render();
|
|
208
|
+
},
|
|
209
|
+
focus: () => textarea.focus(),
|
|
210
|
+
/**
|
|
211
|
+
* Завершает режим ввода, отдавая фокус предыдущей панели.
|
|
212
|
+
* Нужно вызывать перед открытием модального окна: иначе textarea по blur
|
|
213
|
+
* вызывает screen.rewindFocus() и забирает фокус обратно у модалки.
|
|
214
|
+
*/
|
|
215
|
+
release: () => {
|
|
216
|
+
if (textarea._reading && typeof textarea._done === "function") {
|
|
217
|
+
textarea._done("stop");
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import blessed from "neo-blessed";
|
|
2
|
+
import { escapeBlessed } from "../../../telegram/formatter.js";
|
|
3
|
+
import { fg } from "../../theme.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Создаёт модальное окно контекстных действий над сообщением.
|
|
7
|
+
* @param {blessed.Widgets.Screen} screen
|
|
8
|
+
* @param {object} theme
|
|
9
|
+
* @param {object} callbacks
|
|
10
|
+
* @param {(action: string, msg: object) => void} callbacks.onAction
|
|
11
|
+
* @returns {{ show: (msg: object) => void, hide: () => void }}
|
|
12
|
+
*/
|
|
13
|
+
export function createActionModal(screen, theme, { onAction } = {}) {
|
|
14
|
+
const modal = blessed.box({
|
|
15
|
+
parent: screen,
|
|
16
|
+
top: "center",
|
|
17
|
+
left: "center",
|
|
18
|
+
width: "50%",
|
|
19
|
+
height: "55%",
|
|
20
|
+
hidden: true,
|
|
21
|
+
tags: true,
|
|
22
|
+
border: {
|
|
23
|
+
type: "line",
|
|
24
|
+
},
|
|
25
|
+
shadow: true,
|
|
26
|
+
style: {
|
|
27
|
+
bg: theme.modal.bg,
|
|
28
|
+
fg: theme.modal.fg,
|
|
29
|
+
border: {
|
|
30
|
+
fg: theme.modal.borderFg,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const header = blessed.box({
|
|
36
|
+
parent: modal,
|
|
37
|
+
top: 0,
|
|
38
|
+
left: 1,
|
|
39
|
+
right: 1,
|
|
40
|
+
height: 2,
|
|
41
|
+
tags: true,
|
|
42
|
+
style: {
|
|
43
|
+
bg: theme.modal.bg,
|
|
44
|
+
fg: theme.modal.fg,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const list = blessed.list({
|
|
49
|
+
parent: modal,
|
|
50
|
+
top: 2,
|
|
51
|
+
left: 1,
|
|
52
|
+
right: 1,
|
|
53
|
+
bottom: 1,
|
|
54
|
+
tags: true,
|
|
55
|
+
keys: true,
|
|
56
|
+
vi: true,
|
|
57
|
+
mouse: true,
|
|
58
|
+
style: {
|
|
59
|
+
bg: theme.modal.bg,
|
|
60
|
+
fg: theme.modal.fg,
|
|
61
|
+
selected: {
|
|
62
|
+
bg: theme.modal.selectedBg,
|
|
63
|
+
fg: theme.modal.selectedFg,
|
|
64
|
+
bold: true,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let currentMsg = null;
|
|
70
|
+
let currentActions = [];
|
|
71
|
+
let previousFocus = null;
|
|
72
|
+
|
|
73
|
+
function hide() {
|
|
74
|
+
modal.hide();
|
|
75
|
+
if (previousFocus) {
|
|
76
|
+
previousFocus.focus();
|
|
77
|
+
previousFocus = null;
|
|
78
|
+
}
|
|
79
|
+
screen.render();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function show(msg) {
|
|
83
|
+
if (!msg) return;
|
|
84
|
+
currentMsg = msg;
|
|
85
|
+
|
|
86
|
+
const snippet = escapeBlessed((msg.text || msg.mediaDescription || "").slice(0, 35));
|
|
87
|
+
header.setContent(
|
|
88
|
+
` {bold}Действия над сообщением #${msg.id}{/bold}\n ${fg(theme.modal.hintFg, `"${snippet}..."`)}`
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
currentActions = [
|
|
92
|
+
{ id: "reply", label: "↩️ Ответить (Reply)" },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
if (msg.out) {
|
|
96
|
+
currentActions.push({ id: "edit", label: "✏️ Редактировать текст" });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
currentActions.push(
|
|
100
|
+
{ id: "delete", label: "🗑️ Удалить сообщение" },
|
|
101
|
+
{ id: "react_like", label: "👍 Поставить реакцию 👍" },
|
|
102
|
+
{ id: "react_fire", label: "🔥 Поставить реакцию 🔥" },
|
|
103
|
+
{ id: "react_heart", label: "❤️ Поставить реакцию ❤️" }
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
if (msg.media) {
|
|
107
|
+
currentActions.push({ id: "download", label: "📥 Скачать медиа-вложение" });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
currentActions.push({ id: "copy", label: "📋 Скопировать текст в ввод" });
|
|
111
|
+
|
|
112
|
+
list.setItems(currentActions.map((a) => a.label));
|
|
113
|
+
previousFocus = screen.focused;
|
|
114
|
+
modal.show();
|
|
115
|
+
modal.setFront();
|
|
116
|
+
list.focus();
|
|
117
|
+
screen.render();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
list.on("select", (item, index) => {
|
|
121
|
+
const action = currentActions[index];
|
|
122
|
+
if (action && currentMsg) {
|
|
123
|
+
hide();
|
|
124
|
+
onAction?.(action.id, currentMsg);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Фокус получает список — на нём и живут клавиши закрытия.
|
|
129
|
+
list.key(["escape", "q"], hide);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
modal,
|
|
133
|
+
show,
|
|
134
|
+
hide,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import blessed from "neo-blessed";
|
|
2
|
+
import { escapeBlessed } from "../../../telegram/formatter.js";
|
|
3
|
+
import { idToString } from "../../../telegram/entities.js";
|
|
4
|
+
import { fg } from "../../theme.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Создаёт модальное окно информации о текущем чате.
|
|
8
|
+
* @param {blessed.Widgets.Screen} screen
|
|
9
|
+
* @param {object} theme
|
|
10
|
+
* @returns {{ show: (chat: object) => void, hide: () => void }}
|
|
11
|
+
*/
|
|
12
|
+
export function createChatInfoModal(screen, theme) {
|
|
13
|
+
const modal = blessed.box({
|
|
14
|
+
parent: screen,
|
|
15
|
+
top: "center",
|
|
16
|
+
left: "center",
|
|
17
|
+
width: "60%",
|
|
18
|
+
height: "60%",
|
|
19
|
+
hidden: true,
|
|
20
|
+
tags: true,
|
|
21
|
+
border: {
|
|
22
|
+
type: "line",
|
|
23
|
+
},
|
|
24
|
+
shadow: true,
|
|
25
|
+
style: {
|
|
26
|
+
bg: theme.modal.bg,
|
|
27
|
+
fg: theme.modal.fg,
|
|
28
|
+
border: {
|
|
29
|
+
fg: theme.modal.borderFg,
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const infoText = blessed.box({
|
|
35
|
+
parent: modal,
|
|
36
|
+
top: 1,
|
|
37
|
+
left: 2,
|
|
38
|
+
right: 2,
|
|
39
|
+
bottom: 3,
|
|
40
|
+
tags: true,
|
|
41
|
+
scrollable: true,
|
|
42
|
+
style: {
|
|
43
|
+
bg: theme.modal.bg,
|
|
44
|
+
fg: theme.modal.fg,
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const closeBtn = blessed.button({
|
|
49
|
+
parent: modal,
|
|
50
|
+
bottom: 1,
|
|
51
|
+
left: "center",
|
|
52
|
+
width: 16,
|
|
53
|
+
height: 1,
|
|
54
|
+
mouse: true,
|
|
55
|
+
content: " [ Закрыть ] ",
|
|
56
|
+
align: "center",
|
|
57
|
+
tags: true,
|
|
58
|
+
style: {
|
|
59
|
+
bg: theme.accent,
|
|
60
|
+
fg: theme.onAccent,
|
|
61
|
+
focus: {
|
|
62
|
+
bg: theme.modal.buttonFocusBg,
|
|
63
|
+
fg: theme.modal.buttonFocusFg,
|
|
64
|
+
bold: true,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
let previousFocus = null;
|
|
70
|
+
|
|
71
|
+
function hide() {
|
|
72
|
+
modal.hide();
|
|
73
|
+
if (previousFocus) {
|
|
74
|
+
previousFocus.focus();
|
|
75
|
+
previousFocus = null;
|
|
76
|
+
}
|
|
77
|
+
screen.render();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function show(chat) {
|
|
81
|
+
if (!chat) return;
|
|
82
|
+
|
|
83
|
+
const entity = chat.entity || {};
|
|
84
|
+
const title = escapeBlessed(chat.title || "Без названия");
|
|
85
|
+
const id = escapeBlessed(idToString(chat.id));
|
|
86
|
+
const username = chat.username ? `@${escapeBlessed(chat.username)}` : "—";
|
|
87
|
+
const type = escapeBlessed(chat.type || "unknown");
|
|
88
|
+
const participants = entity.participantsCount ? `${entity.participantsCount}` : "Неизвестно";
|
|
89
|
+
const isMuted = chat.isMuted
|
|
90
|
+
? fg(theme.warning, "Выключены")
|
|
91
|
+
: fg(theme.success, "Включены");
|
|
92
|
+
const about = escapeBlessed(entity.about || "Нет описания");
|
|
93
|
+
|
|
94
|
+
const body = `
|
|
95
|
+
{bold}{underline}ℹ Информация о чате{/underline}{/bold}
|
|
96
|
+
|
|
97
|
+
{bold}Название:{/bold} ${title}
|
|
98
|
+
{bold}Тип:{/bold} ${type}
|
|
99
|
+
{bold}ID:{/bold} ${id}
|
|
100
|
+
{bold}Username:{/bold} ${username}
|
|
101
|
+
{bold}Участников:{/bold} ${participants}
|
|
102
|
+
{bold}Уведомления:{/bold} ${isMuted}
|
|
103
|
+
|
|
104
|
+
{bold}О чате / О себе:{/bold}
|
|
105
|
+
${about}
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
infoText.setContent(body);
|
|
109
|
+
previousFocus = screen.focused;
|
|
110
|
+
modal.show();
|
|
111
|
+
modal.setFront();
|
|
112
|
+
closeBtn.focus();
|
|
113
|
+
screen.render();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
closeBtn.on("press", hide);
|
|
117
|
+
// Клавиши вешаем на кнопку: blessed отдаёт события только сфокусированному элементу.
|
|
118
|
+
closeBtn.key(["escape", "q"], hide);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
modal,
|
|
122
|
+
show,
|
|
123
|
+
hide,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import blessed from "neo-blessed";
|
|
2
|
+
import { fg } from "../../theme.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Создаёт модальное окно подтверждения действия (Да / Нет).
|
|
6
|
+
*
|
|
7
|
+
* Важно: blessed рассылает клавиши только СФОКУСИРОВАННОМУ элементу, поэтому
|
|
8
|
+
* обработчики висят на самих кнопках, а не на контейнере модалки.
|
|
9
|
+
* @param {blessed.Widgets.Screen} screen
|
|
10
|
+
* @param {object} theme
|
|
11
|
+
* @returns {{ ask: (message: string, onConfirm: () => void) => void, hide: () => void }}
|
|
12
|
+
*/
|
|
13
|
+
export function createConfirmModal(screen, theme) {
|
|
14
|
+
const modal = blessed.box({
|
|
15
|
+
parent: screen,
|
|
16
|
+
top: "center",
|
|
17
|
+
left: "center",
|
|
18
|
+
width: "50%",
|
|
19
|
+
height: 8,
|
|
20
|
+
hidden: true,
|
|
21
|
+
tags: true,
|
|
22
|
+
border: {
|
|
23
|
+
type: "line",
|
|
24
|
+
},
|
|
25
|
+
shadow: true,
|
|
26
|
+
style: {
|
|
27
|
+
bg: theme.modal.bg,
|
|
28
|
+
fg: theme.modal.fg,
|
|
29
|
+
border: {
|
|
30
|
+
fg: theme.warning,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const msgBox = blessed.box({
|
|
36
|
+
parent: modal,
|
|
37
|
+
top: 1,
|
|
38
|
+
left: 2,
|
|
39
|
+
right: 2,
|
|
40
|
+
height: 2,
|
|
41
|
+
align: "center",
|
|
42
|
+
tags: true,
|
|
43
|
+
style: {
|
|
44
|
+
bg: theme.modal.bg,
|
|
45
|
+
fg: theme.modal.fg,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const hint = blessed.box({
|
|
50
|
+
parent: modal,
|
|
51
|
+
bottom: 0,
|
|
52
|
+
left: 2,
|
|
53
|
+
right: 2,
|
|
54
|
+
height: 1,
|
|
55
|
+
align: "center",
|
|
56
|
+
tags: true,
|
|
57
|
+
content: fg(theme.modal.hintFg, "[←/→ или Tab] Выбор [Enter] Подтвердить [Y] Да [N/Esc] Нет"),
|
|
58
|
+
style: {
|
|
59
|
+
bg: theme.modal.bg,
|
|
60
|
+
fg: theme.modal.fg,
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const yesBtn = blessed.button({
|
|
65
|
+
parent: modal,
|
|
66
|
+
bottom: 2,
|
|
67
|
+
left: 6,
|
|
68
|
+
width: 12,
|
|
69
|
+
height: 1,
|
|
70
|
+
mouse: true,
|
|
71
|
+
content: " [ Да ] ",
|
|
72
|
+
align: "center",
|
|
73
|
+
style: {
|
|
74
|
+
bg: theme.modal.dangerBg,
|
|
75
|
+
fg: theme.modal.dangerFg,
|
|
76
|
+
focus: { bg: theme.modal.buttonFocusBg, fg: theme.modal.buttonFocusFg, bold: true },
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const noBtn = blessed.button({
|
|
81
|
+
parent: modal,
|
|
82
|
+
bottom: 2,
|
|
83
|
+
right: 6,
|
|
84
|
+
width: 12,
|
|
85
|
+
height: 1,
|
|
86
|
+
mouse: true,
|
|
87
|
+
content: " [ Нет ] ",
|
|
88
|
+
align: "center",
|
|
89
|
+
style: {
|
|
90
|
+
bg: theme.modal.neutralBg,
|
|
91
|
+
fg: theme.modal.neutralFg,
|
|
92
|
+
focus: { bg: theme.modal.buttonFocusBg, fg: theme.modal.buttonFocusFg, bold: true },
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
let currentCallback = null;
|
|
97
|
+
let previousFocus = null;
|
|
98
|
+
|
|
99
|
+
function hide() {
|
|
100
|
+
modal.hide();
|
|
101
|
+
currentCallback = null;
|
|
102
|
+
if (previousFocus) {
|
|
103
|
+
previousFocus.focus();
|
|
104
|
+
previousFocus = null;
|
|
105
|
+
}
|
|
106
|
+
screen.render();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function confirm() {
|
|
110
|
+
const cb = currentCallback;
|
|
111
|
+
hide();
|
|
112
|
+
cb?.();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
yesBtn.on("press", confirm);
|
|
116
|
+
noBtn.on("press", hide);
|
|
117
|
+
|
|
118
|
+
// Клавиши вешаем на обе кнопки — активна всегда одна из них.
|
|
119
|
+
for (const btn of [yesBtn, noBtn]) {
|
|
120
|
+
btn.key(["escape", "n"], hide);
|
|
121
|
+
btn.key(["y"], confirm);
|
|
122
|
+
btn.key(["left", "right", "tab", "S-tab", "h", "l"], () => {
|
|
123
|
+
(screen.focused === yesBtn ? noBtn : yesBtn).focus();
|
|
124
|
+
screen.render();
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
modal,
|
|
130
|
+
ask: (text, onConfirm) => {
|
|
131
|
+
currentCallback = onConfirm;
|
|
132
|
+
previousFocus = screen.focused;
|
|
133
|
+
msgBox.setContent(`{bold}${text}{/bold}`);
|
|
134
|
+
modal.show();
|
|
135
|
+
modal.setFront();
|
|
136
|
+
noBtn.focus();
|
|
137
|
+
screen.render();
|
|
138
|
+
},
|
|
139
|
+
hide,
|
|
140
|
+
};
|
|
141
|
+
}
|