eva-md 0.4.3 → 0.5.1

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.
Files changed (2) hide show
  1. package/eva.js +225 -172
  2. package/package.json +1 -1
package/eva.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * EVA.md v0.3.3 — EVA Media Downloader by .maxon
3
+ * EVA.md v0.5.1 — EVA Media Downloader by .maxon
4
4
  * Pure Node.js Interactive TUI (Zero external npm dependencies!)
5
5
  * Works just like Gemini CLI (`node eva.js` or `./eva.js`), with full mouse & keyboard support.
6
6
  *
@@ -46,7 +46,7 @@ const { spawn, spawnSync } = require('child_process');
46
46
  // Constants & Configuration
47
47
  // ---------------------------------------------------------------------------
48
48
 
49
- const APP_VERSION = 'v0.4.3 (Node.js)';
49
+ const APP_VERSION = 'v0.5.1 (Node.js)';
50
50
  const CONFIG_PATH = path.join(os.homedir(), '.config', 'eva_md', 'config.json');
51
51
  const MAX_LOG_LINES = 200;
52
52
  const MAX_LOG_LINE_LENGTH = 500;
@@ -104,7 +104,6 @@ const ANSI = {
104
104
  fgBlack: `${CSI}30m`,
105
105
  fgGray: `${CSI}38;2;105;105;105m`,
106
106
  fgOrange: `${CSI}38;2;222;91;9m`, // Текст цвета RGB(222, 91, 9)
107
- bgOrange: `${CSI}48;2;222;91;9m`,
108
107
  bgGray: `${CSI}48;2;105;105;105m`,
109
108
  bgWhite: `${CSI}47m`,
110
109
  bgCyan: `${CSI}46m`,
@@ -143,6 +142,10 @@ function shellQuote(arg) {
143
142
  return arg;
144
143
  }
145
144
 
145
+ function resolveOutputDir(outputDir) {
146
+ return outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
147
+ }
148
+
146
149
  function formatBytesToMB(bytes) {
147
150
  if (!bytes || bytes <= 0) return '—';
148
151
  return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
@@ -341,7 +344,7 @@ function buildYtdlpArgs(opts, targetUrl = null) {
341
344
  if (opts.writeThumbnail) args.push('--write-thumbnail');
342
345
 
343
346
  // File Naming & Archive
344
- const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
347
+ const outDir = resolveOutputDir(opts.outputDir);
345
348
  const tpl = opts.usePlaylistTemplate ? opts.outputTemplate : '%(title)s.%(ext)s';
346
349
  args.push('-o', path.join(outDir, tpl));
347
350
 
@@ -480,7 +483,7 @@ class DownloadManager {
480
483
  _createQueueItem(opts, url, title = null, entry = null, entryIndex = 0) {
481
484
  const src = detectSource(url, opts.sourceId);
482
485
  const fmt = opts.bestVideoMerge && !opts.extractAudio ? 'mp4/mkv' : opts.audioFormat;
483
- const outDir = opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
486
+ const outDir = resolveOutputDir(opts.outputDir);
484
487
  const itemOpts = { ...opts, usePlaylistItems: false, playlistReverse: false };
485
488
  if (entry && itemOpts.usePlaylistTemplate) {
486
489
  const playlistIndex = String(entry.playlist_index || entryIndex + 1).padStart(2, '0');
@@ -987,6 +990,7 @@ class ScreenBuffer {
987
990
  constructor(cols, rows) {
988
991
  this.cols = cols;
989
992
  this.rows = rows;
993
+ this.clipRight = null;
990
994
  this.chars = Array.from({ length: rows }, () => Array(cols).fill(' '));
991
995
  this.styles = Array.from({ length: rows }, () => Array(cols).fill(ANSI.fgWhite));
992
996
  }
@@ -996,7 +1000,7 @@ class ScreenBuffer {
996
1000
  const str = String(text);
997
1001
  for (let i = 0; i < str.length; i++) {
998
1002
  const cx = x + i;
999
- if (cx >= 0 && cx < this.cols - 1) {
1003
+ if (cx >= 0 && cx < this.cols - 1 && (this.clipRight === null || cx <= this.clipRight)) {
1000
1004
  this.chars[y][cx] = str[i];
1001
1005
  this.styles[y][cx] = style;
1002
1006
  }
@@ -1053,10 +1057,11 @@ class EvaNodeTUI {
1053
1057
  this.hitboxes = [];
1054
1058
  this.selectedQueueIdx = 0;
1055
1059
  this.selectedQueueItem = null;
1060
+ this.queueScrollOffset = 0;
1061
+ this.queueViewport = null;
1056
1062
  this.statusBanner = '';
1057
1063
 
1058
- // Inline modal prompt state
1059
- this.modal = null; // { label, value, cursor, onConfirm }
1064
+ this.modal = null;
1060
1065
  }
1061
1066
 
1062
1067
  start() {
@@ -1081,101 +1086,84 @@ class EvaNodeTUI {
1081
1086
  process.exit(0);
1082
1087
  }
1083
1088
 
1084
- registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
1085
- this.focusOrder.push(id);
1086
- const isFocused = this.focusId === id;
1087
-
1088
- // Префикс и его цвет
1089
- let prefix = '';
1090
- let prefixStyle = ANSI.fgWhite;
1091
-
1092
- if (checked !== null) {
1093
- prefix = checked ? '[x] ' : '[ ] ';
1094
- prefixStyle = checked
1095
- ? ANSI.fgGreen + ANSI.bold
1096
- : ANSI.dim + ANSI.fgWhite;
1097
- } else if (radio !== null) {
1098
- prefix = radio ? '(•) ' : '( ) ';
1099
- prefixStyle = radio
1100
- ? ANSI.fgCyan + ANSI.bold
1101
- : ANSI.dim + ANSI.fgWhite;
1102
- }
1103
-
1104
- // Стиль основного текста
1105
- let style = ANSI.fgGray;
1106
-
1107
- if (isFocused) {
1108
- style = isCta
1109
- ? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
1110
- : ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
1111
- } else if (isCta) {
1112
- style = id === 'url_input'
1113
- ? ANSI.fgWhite + ANSI.bold
1114
- : id === 'btn_add_queue'
1115
- ? ANSI.fgGreen + ANSI.bold
1116
- : ANSI.fgCyan + ANSI.bold;
1117
- } else if (checked || radio) {
1118
- style = ANSI.fgOrange + ANSI.bold;
1119
- }
1120
-
1121
- // Вывод префикса с раздельными цветами
1122
- if (checked !== null) {
1123
- scr.putStr(y, x, '[', ANSI.fgGray);
1089
+ registerWidget(scr, id, y, x, label, action, { checked = null, radio = null, isCta = false, maxWidth = 0 } = {}) {
1090
+ this.focusOrder.push(id);
1091
+ const isFocused = this.focusId === id;
1092
+ let prefix = '';
1093
+
1094
+ if (checked !== null) {
1095
+ prefix = checked ? '[x] ' : '[ ] ';
1096
+ } else if (radio !== null) {
1097
+ prefix = radio ? '(•) ' : '( ) ';
1098
+ }
1099
+
1100
+ let style = ANSI.fgGray;
1101
+ if (isFocused) {
1102
+ style = isCta
1103
+ ? ANSI.bgGray + ANSI.fgWhite + ANSI.bold
1104
+ : ANSI.bgWhite + ANSI.fgBlack + ANSI.bold;
1105
+ } else if (isCta) {
1106
+ style = id === 'url_input'
1107
+ ? ANSI.fgWhite + ANSI.bold
1108
+ : id === 'btn_add_queue'
1109
+ ? ANSI.fgGreen + ANSI.bold
1110
+ : ANSI.fgCyan + ANSI.bold;
1111
+ } else if (checked || radio) {
1112
+ style = ANSI.fgOrange + ANSI.bold;
1113
+ }
1114
+
1115
+ if (checked !== null) {
1116
+ scr.putStr(y, x, '[', ANSI.fgGray);
1124
1117
  scr.putStr(y, x + 1, checked ? 'x' : ' ', checked ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
1125
- scr.putStr(y, x + 2, '] ', ANSI.fgGray);
1126
- } else if (radio !== null) {
1127
- scr.putStr(y, x, '(', ANSI.fgGray);
1128
- scr.putStr(y, x + 1, radio ? '•' : ' ', radio ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
1129
- scr.putStr(y, x + 2, ') ', ANSI.fgGray);
1130
- }
1131
-
1132
- // Расчёт доступной ширины текста
1133
- const availableWidth = maxWidth > 0
1134
- ? Math.max(0, maxWidth - prefix.length)
1135
- : Infinity;
1136
-
1137
- let visibleLabel = label;
1118
+ scr.putStr(y, x + 2, '] ', ANSI.fgGray);
1119
+ } else if (radio !== null) {
1120
+ scr.putStr(y, x, '(', ANSI.fgGray);
1121
+ scr.putStr(y, x + 1, radio ? '•' : ' ', radio ? ANSI.fgOrange + ANSI.bold : ANSI.dim);
1122
+ scr.putStr(y, x + 2, ') ', ANSI.fgGray);
1123
+ }
1138
1124
 
1139
- if (visibleLabel.length > availableWidth) {
1140
- visibleLabel = availableWidth > 0
1141
- ? visibleLabel.slice(0, availableWidth - 1) + '…'
1142
- : '';
1143
- }
1144
-
1145
- // Вывод основного текста
1146
- const textX = x + (prefix ? prefix.length : 0);
1147
- const editMarker = '[ред.]';
1148
- const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
1149
- if (!isFocused && editMarkerIndex >= 0) {
1150
- const folderLabel = 'Папка:';
1151
- const folderLabelIndex = visibleLabel.indexOf(folderLabel);
1152
- if (folderLabelIndex >= 0 && folderLabelIndex < editMarkerIndex) {
1153
- scr.putStr(y, textX, visibleLabel.slice(0, folderLabelIndex), style);
1154
- scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
1155
- const afterFolderIndex = folderLabelIndex + folderLabel.length;
1156
- scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
1125
+ const availableWidth = maxWidth > 0 ? Math.max(0, maxWidth - prefix.length) : Infinity;
1126
+ let visibleLabel = label;
1127
+ if (visibleLabel.length > availableWidth) {
1128
+ visibleLabel = availableWidth > 0 ? visibleLabel.slice(0, availableWidth - 1) + '…' : '';
1129
+ }
1130
+
1131
+ const textX = x + prefix.length;
1132
+ const editMarker = '[ред.]';
1133
+ const editMarkerIndex = visibleLabel.lastIndexOf(editMarker);
1134
+ if (!isFocused && editMarkerIndex >= 0) {
1135
+ const folderLabel = 'Папка:';
1136
+ const folderLabelIndex = visibleLabel.indexOf(folderLabel);
1137
+ if (folderLabelIndex >= 0 && folderLabelIndex < editMarkerIndex) {
1138
+ scr.putStr(y, textX, visibleLabel.slice(0, folderLabelIndex), style);
1139
+ scr.putStr(y, textX + folderLabelIndex, folderLabel, ANSI.fgWhite);
1140
+ const afterFolderIndex = folderLabelIndex + folderLabel.length;
1141
+ scr.putStr(y, textX + afterFolderIndex, visibleLabel.slice(afterFolderIndex, editMarkerIndex), style);
1142
+ } else {
1143
+ scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
1144
+ }
1145
+ scr.putStr(y, textX + editMarkerIndex, editMarker, ANSI.fgWhite);
1146
+ scr.putStr(
1147
+ y,
1148
+ textX + editMarkerIndex + editMarker.length,
1149
+ visibleLabel.slice(editMarkerIndex + editMarker.length),
1150
+ style
1151
+ );
1157
1152
  } else {
1158
- scr.putStr(y, textX, visibleLabel.slice(0, editMarkerIndex), style);
1153
+ scr.putStr(y, textX, visibleLabel, style);
1159
1154
  }
1160
- scr.putStr(y, textX + editMarkerIndex, editMarker, ANSI.fgWhite);
1161
- scr.putStr(y, textX + editMarkerIndex + editMarker.length, visibleLabel.slice(editMarkerIndex + editMarker.length), style);
1162
- } else {
1163
- scr.putStr(y, textX, visibleLabel, style);
1155
+
1156
+ const totalLength = prefix.length + visibleLabel.length;
1157
+ this.hitboxes.push({
1158
+ id,
1159
+ y,
1160
+ x1: x,
1161
+ x2: Math.min(x + totalLength - 1, scr.clipRight === null ? scr.cols - 2 : scr.clipRight),
1162
+ action,
1163
+ });
1164
+
1165
+ return x + totalLength;
1164
1166
  }
1165
-
1166
- // Область взаимодействия
1167
- const totalLength = (prefix ? prefix.length : 0) + visibleLabel.length;
1168
-
1169
- this.hitboxes.push({
1170
- id,
1171
- y,
1172
- x1: x,
1173
- x2: x + totalLength - 1,
1174
- action
1175
- });
1176
-
1177
- return x + totalLength;
1178
- }
1179
1167
 
1180
1168
 
1181
1169
  openPrompt(label, currentVal, onConfirm) {
@@ -1183,7 +1171,6 @@ class EvaNodeTUI {
1183
1171
  this.modal = {
1184
1172
  label,
1185
1173
  value: val,
1186
- cursor: val.length,
1187
1174
  onConfirm,
1188
1175
  };
1189
1176
  this.render();
@@ -1206,6 +1193,7 @@ class EvaNodeTUI {
1206
1193
 
1207
1194
  this.focusOrder = [];
1208
1195
  this.hitboxes = [];
1196
+ this.queueViewport = null;
1209
1197
 
1210
1198
  if (rows < 22 || cols < 76) {
1211
1199
  scr.putStr(1, 2, `Окно терминала слишком маленькое (${cols}x${rows}). Минимум: 78x22.`, ANSI.fgRed + ANSI.bold);
@@ -1213,7 +1201,19 @@ class EvaNodeTUI {
1213
1201
  return;
1214
1202
  }
1215
1203
 
1216
- // 1. Header Logo & Tagline
1204
+ this._renderHeader(scr, cols);
1205
+ const { x, w } = this._renderNavigation(scr, rows, cols);
1206
+ this._renderActiveTab(scr, x, w, rows);
1207
+
1208
+ if (this.focusOrder.length > 0 && !this.focusOrder.includes(this.focusId)) {
1209
+ this.focusId = this.focusOrder[0];
1210
+ }
1211
+
1212
+ this._renderFooter(scr, cols, rows);
1213
+ process.stdout.write(scr.renderToAnsi());
1214
+ }
1215
+
1216
+ _renderHeader(scr, cols) {
1217
1217
  ASCII_LOGO.forEach((line, i) => {
1218
1218
  scr.putStr(1 + i, 2, line, ANSI.fgWhite + ANSI.bold);
1219
1219
  });
@@ -1224,8 +1224,9 @@ class EvaNodeTUI {
1224
1224
  scr.putStr(5, cols - tagline.length - 3, tagline, ANSI.fgWhite);
1225
1225
  scr.putStr(6, cols - tagline.length - 3, '─'.repeat(tagline.length), ANSI.fgCyan);
1226
1226
  }
1227
+ }
1227
1228
 
1228
- // 2. Left Sidebar Navigation (Strictly 1-cell narrow CP437/WGL4 characters so borders NEVER shift)
1229
+ _renderNavigation(scr, rows, cols) {
1229
1230
  const sidebarW = 19;
1230
1231
  const navItems = [
1231
1232
  { tab: 1, label: '≡ General' },
@@ -1258,21 +1259,20 @@ class EvaNodeTUI {
1258
1259
  scr.putStr(r, sidebarW + 1, '│', ANSI.fgWhite);
1259
1260
  }
1260
1261
 
1261
- // 3. Main Content Area
1262
1262
  const mainX = sidebarW + 3;
1263
1263
  const mainW = cols - mainX - 2;
1264
+ return { x: mainX, w: mainW };
1265
+ }
1264
1266
 
1265
- if (this.activeTab === 1) this._renderDownloadTab(scr, mainX, mainW, rows);
1266
- else if (this.activeTab === 2) this._renderQueueTab(scr, mainX, mainW, rows);
1267
- else if (this.activeTab === 3) this._renderLibraryTab(scr, mainX, mainW, rows);
1268
- else if (this.activeTab === 4) this._renderSettingsTab(scr, mainX, mainW, rows);
1269
- else if (this.activeTab === 5) this._renderAboutTab(scr, mainX, mainW, rows);
1270
-
1271
- if (this.focusOrder.length > 0 && !this.focusOrder.includes(this.focusId)) {
1272
- this.focusId = this.focusOrder[0];
1273
- }
1267
+ _renderActiveTab(scr, x, w, rows) {
1268
+ if (this.activeTab === 1) this._renderDownloadTab(scr, x, w, rows);
1269
+ else if (this.activeTab === 2) this._renderQueueTab(scr, x, w, rows);
1270
+ else if (this.activeTab === 3) this._renderLibraryTab(scr, x, w, rows);
1271
+ else if (this.activeTab === 4) this._renderSettingsTab(scr, x, w, rows);
1272
+ else if (this.activeTab === 5) this._renderAboutTab(scr, x, w, rows);
1273
+ }
1274
1274
 
1275
- // 4. Bottom Status Bar with Live Queue Counter & Progress Bar (or Modal Input)
1275
+ _renderFooter(scr, cols, rows) {
1276
1276
  if (this.modal) {
1277
1277
  const prefix = ` * ${this.modal.label}: `;
1278
1278
  scr.putStr(rows - 2, 0, prefix, ANSI.bgCyan + ANSI.fgBlack + ANSI.bold);
@@ -1283,7 +1283,7 @@ class EvaNodeTUI {
1283
1283
  scr.putStr(rows - 1, 2, footerLeft, ANSI.fgCyan + ANSI.bold);
1284
1284
 
1285
1285
  const items = this.manager.items;
1286
- const activeItems = items.filter((i) => i.status === 'Downloading');
1286
+ const activeItems = items.filter((item) => item.status === 'Downloading');
1287
1287
  const doneCnt = items.filter((i) => i.status === 'Done').length;
1288
1288
 
1289
1289
  let pct = 0;
@@ -1294,22 +1294,16 @@ class EvaNodeTUI {
1294
1294
  pct = 100;
1295
1295
  }
1296
1296
 
1297
- const barW = 12;
1298
- const filled = Math.round((pct / 100) * barW);
1299
- const bar = '█'.repeat(Math.max(0, filled)) + '░'.repeat(Math.max(0, barW - filled));
1300
-
1301
- const queueStatusStr = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
1302
- const qStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
1303
- const qStartX = cols - queueStatusStr.length - 0;
1304
- scr.putStr(rows - 1, qStartX, queueStatusStr, qStyle);
1297
+ const queueStatus = `│ ${activeItems.length}↓ ${doneCnt}✓ ${pct}%`;
1298
+ const queueStyle = activeItems.length > 0 ? ANSI.fgGreen + ANSI.bold : ANSI.fgWhite;
1299
+ const queueStartX = cols - queueStatus.length;
1300
+ scr.putStr(rows - 1, queueStartX, queueStatus, queueStyle);
1305
1301
 
1306
- const afterQueueX = qStartX + queueStatusStr.length + 2;
1307
- if (this.statusBanner && afterQueueX < cols - 10) {
1308
- scr.putStr(rows - 1, afterQueueX, `│ ${this.statusBanner}`.slice(0, cols - afterQueueX - 2), ANSI.fgCyan);
1302
+ const statusX = queueStartX + queueStatus.length + 2;
1303
+ if (this.statusBanner && statusX < cols - 10) {
1304
+ scr.putStr(rows - 1, statusX, `│ ${this.statusBanner}`.slice(0, cols - statusX - 2), ANSI.fgCyan);
1309
1305
  }
1310
1306
  }
1311
-
1312
- process.stdout.write(scr.renderToAnsi());
1313
1307
  }
1314
1308
 
1315
1309
  // -------------------------------------------------------------------------
@@ -1318,9 +1312,11 @@ class EvaNodeTUI {
1318
1312
 
1319
1313
  _renderDownloadTab(scr, x, w, rows) {
1320
1314
  let y = 8;
1315
+ const previousClipRight = scr.clipRight;
1321
1316
 
1322
1317
  // --- Box 1: Select a source (1-9) & URL input ---
1323
1318
  scr.drawBox(y, x, 7, w, 'Select a source');
1319
+ scr.clipRight = x + w - 2;
1324
1320
  const colWidth = Math.floor(w / 3);
1325
1321
  const col1X = x + 2;
1326
1322
  const col2X = x + colWidth;
@@ -1344,7 +1340,9 @@ class EvaNodeTUI {
1344
1340
  );
1345
1341
  });
1346
1342
 
1343
+ scr.clipRight = previousClipRight;
1347
1344
  scr.putStr(y + 4, x + 0, '├' + '─'.repeat(w - 2) + '┤', ANSI.fgWhite);
1345
+ scr.clipRight = x + w - 2;
1348
1346
  const srcObj = SOURCES.find((s) => s.id === this.opts.sourceId) || SOURCES[0];
1349
1347
  const urlDisp = this.opts.url ? this.opts.url : `(нажмите 'u' или кликните для ввода URL — ${srcObj.hint})`;
1350
1348
  this.registerWidget(
@@ -1361,7 +1359,9 @@ class EvaNodeTUI {
1361
1359
 
1362
1360
  // --- Box 2: Download Options & yt-dlp Flags ---
1363
1361
  const box2H = 11;
1362
+ scr.clipRight = previousClipRight;
1364
1363
  scr.drawBox(y, x, box2H, w, 'Download Options Flags');
1364
+ scr.clipRight = x + w - 2;
1365
1365
  const c1 = x + 2;
1366
1366
 
1367
1367
  // Row 1: Audio (-x, metadata, thumbnail)
@@ -1472,43 +1472,47 @@ class EvaNodeTUI {
1472
1472
 
1473
1473
  // Row 6: Naming & Archive (-o & --download-archive)
1474
1474
  scr.putStr(y + 6, c1, 'ФАЙЛЫ:', ANSI.fgCyan + ANSI.bold);
1475
- const maxTplLen = Math.max(14, Math.floor((w - 20) / 4));
1476
- const shortTpl =
1477
- this.opts.outputTemplate.length > maxTplLen
1478
- ? this.opts.outputTemplate.slice(0, maxTplLen - 1) + '…'
1479
- : this.opts.outputTemplate;
1480
- let nx =
1481
- this.registerWidget(
1482
- scr,
1483
- 'flag_otpl',
1484
- y + 6,
1485
- c1 + 9,
1486
- `-o "${shortTpl}"`,
1487
- () => this._toggle('usePlaylistTemplate'),
1488
- { checked: this.opts.usePlaylistTemplate }
1489
- ) + 1;
1490
- nx =
1491
- this.registerWidget(scr, 'edit_otpl', y + 6, nx, '[ред.]', () =>
1492
- this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
1493
- this.opts.outputTemplate = v || '%(title)s.%(ext)s';
1494
- })
1495
- ) + 3;
1475
+ const rowRight = x + w - 2;
1476
+ const templateX = c1 + 9;
1477
+ const rowWidth = rowRight - templateX + 1;
1478
+ const templateMaxWidth = Math.max(4, 4 + Math.floor(Math.max(0, rowWidth - 24) / 2));
1479
+ const templateEnd = this.registerWidget(
1480
+ scr,
1481
+ 'flag_otpl',
1482
+ y + 6,
1483
+ templateX,
1484
+ `-o "${this.opts.outputTemplate}"`,
1485
+ () => this._toggle('usePlaylistTemplate'),
1486
+ {
1487
+ checked: this.opts.usePlaylistTemplate,
1488
+ maxWidth: templateMaxWidth,
1489
+ }
1490
+ );
1491
+ const templateEditEnd = this.registerWidget(scr, 'edit_otpl', y + 6, templateEnd + 1, '[ред.]', () => {
1492
+ return this.openPrompt('Шаблон имени (-o)', this.opts.outputTemplate, (v) => {
1493
+ this.opts.outputTemplate = v || '%(title)s.%(ext)s';
1494
+ });
1495
+ }, { maxWidth: 6 });
1496
1496
 
1497
- let arx =
1498
- this.registerWidget(
1499
- scr,
1500
- 'flag_archive',
1501
- y + 6,
1502
- nx,
1503
- `История загрузки ${this.opts.archiveFile}`,
1504
- () => this._toggle('useDownloadArchive'),
1505
- { checked: this.opts.useDownloadArchive }
1506
- ) + 1;
1507
- this.registerWidget(scr, 'edit_archive', y + 6, arx, '[ред.]', () =>
1508
- this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1509
- this.opts.archiveFile = v || 'archive.txt';
1510
- })
1497
+ const archiveX = templateEditEnd + 2;
1498
+ const archiveMaxWidth = Math.max(4, rowRight - archiveX - '[ред.]'.length);
1499
+ const archiveEnd = this.registerWidget(
1500
+ scr,
1501
+ 'flag_archive',
1502
+ y + 6,
1503
+ archiveX,
1504
+ `История загрузки ${this.opts.archiveFile}`,
1505
+ () => this._toggle('useDownloadArchive'),
1506
+ {
1507
+ checked: this.opts.useDownloadArchive,
1508
+ maxWidth: archiveMaxWidth,
1509
+ }
1511
1510
  );
1511
+ this.registerWidget(scr, 'edit_archive', y + 6, archiveEnd + 1, '[ред.]', () => {
1512
+ return this.openPrompt('Файл архива (--download-archive)', this.opts.archiveFile, (v) => {
1513
+ this.opts.archiveFile = v || 'archive.txt';
1514
+ });
1515
+ }, { maxWidth: 6 });
1512
1516
 
1513
1517
  // Row 7: Playlists (--playlist-items & --playlist-reverse)
1514
1518
  scr.putStr(y + 7, c1, 'ПЛЕЙЛИСТ:', ANSI.fgCyan + ANSI.bold);
@@ -1559,6 +1563,7 @@ class EvaNodeTUI {
1559
1563
  });
1560
1564
 
1561
1565
  y += box2H;
1566
+ scr.clipRight = previousClipRight;
1562
1567
 
1563
1568
  // Word-wrapped CLI Preview Lines (never overflows the window width)
1564
1569
  const fullCliText = `CLI: ${buildCommandPreview(this.opts)}`;
@@ -1595,10 +1600,19 @@ class EvaNodeTUI {
1595
1600
  this.selectedQueueItem = items[this.selectedQueueIdx] || null;
1596
1601
 
1597
1602
  const listRows = Math.max(1, h - 6);
1603
+ this.queueViewport = { x, right: x + w, top: y + 3, rows: listRows };
1604
+ const maxScrollOffset = Math.max(0, items.length - listRows);
1605
+ this.queueScrollOffset = Math.max(0, Math.min(this.queueScrollOffset, maxScrollOffset));
1606
+ if (this.selectedQueueIdx < this.queueScrollOffset) {
1607
+ this.queueScrollOffset = this.selectedQueueIdx;
1608
+ } else if (this.selectedQueueIdx >= this.queueScrollOffset + listRows) {
1609
+ this.queueScrollOffset = this.selectedQueueIdx - listRows + 1;
1610
+ }
1611
+
1598
1612
  if (items.length === 0) {
1599
1613
  scr.putStr(y + 3, x + 4, 'Очередь пуста. Добавьте трек во вкладке [1] Download.', ANSI.fgWhite);
1600
1614
  } else {
1601
- const startI = Math.max(0, this.selectedQueueIdx - listRows + 1);
1615
+ const startI = this.queueScrollOffset;
1602
1616
  const slice = items.slice(startI, startI + listRows);
1603
1617
  slice.forEach((it, rowOffset) => {
1604
1618
  const idx = startI + rowOffset;
@@ -1647,6 +1661,35 @@ class EvaNodeTUI {
1647
1661
  },
1648
1662
  });
1649
1663
  });
1664
+
1665
+ if (items.length > listRows) {
1666
+ const scrollbarX = x + w - 2;
1667
+ const thumbSize = Math.max(1, Math.floor((listRows * listRows) / items.length));
1668
+ const thumbTravel = listRows - thumbSize;
1669
+ const thumbTop = maxScrollOffset > 0
1670
+ ? Math.round((this.queueScrollOffset / maxScrollOffset) * thumbTravel)
1671
+ : 0;
1672
+ for (let rowOffset = 0; rowOffset < listRows; rowOffset++) {
1673
+ const isThumb = rowOffset >= thumbTop && rowOffset < thumbTop + thumbSize;
1674
+ const scrollbarY = y + 3 + rowOffset;
1675
+ scr.putStr(scrollbarY, scrollbarX, isThumb ? '█' : '│', isThumb ? ANSI.fgCyan : ANSI.fgGray);
1676
+ this.hitboxes.push({
1677
+ id: 'q_scroll',
1678
+ y: scrollbarY,
1679
+ x1: scrollbarX,
1680
+ x2: scrollbarX,
1681
+ action: () => {
1682
+ const nextThumbTop = Math.max(0, Math.min(thumbTravel, rowOffset - Math.floor(thumbSize / 2)));
1683
+ this.queueScrollOffset = thumbTravel > 0
1684
+ ? Math.round((nextThumbTop / thumbTravel) * maxScrollOffset)
1685
+ : 0;
1686
+ this.selectedQueueIdx = this.queueScrollOffset;
1687
+ this.selectedQueueItem = items[this.selectedQueueIdx] || null;
1688
+ this.render();
1689
+ },
1690
+ });
1691
+ }
1692
+ }
1650
1693
  }
1651
1694
 
1652
1695
  const ctrlY = y + h - 2;
@@ -1687,7 +1730,7 @@ class EvaNodeTUI {
1687
1730
  _renderLibraryTab(scr, x, w, rows) {
1688
1731
  const h = rows - 9;
1689
1732
  scr.drawBox(8, x, h, w, `Downloaded Media Library (${this.opts.outputDir})`);
1690
- const outDir = this.opts.outputDir.replace(/^~(?=$|\/|\\)/, os.homedir());
1733
+ const outDir = resolveOutputDir(this.opts.outputDir);
1691
1734
  const exts = new Set(['.mp3', '.m4a', '.flac', '.wav', '.mp4', '.mkv', '.webm', '.opus', '.ogg']);
1692
1735
  let files = [];
1693
1736
  try {
@@ -1708,7 +1751,7 @@ class EvaNodeTUI {
1708
1751
  const numberColW = 4;
1709
1752
  const formatColW = 7;
1710
1753
  const sizeColW = 10;
1711
- const nameColW = Math.min(60, Math.max(1, contentWidth - numberColW - formatColW - sizeColW - 5));
1754
+ const nameColW = Math.max(1, contentWidth - numberColW - formatColW - sizeColW - 5);
1712
1755
  const formatX = numberColW + 1 + nameColW + 1;
1713
1756
  const sizeX = formatX + formatColW + 1;
1714
1757
  if (files.length === 0) {
@@ -1722,13 +1765,11 @@ class EvaNodeTUI {
1722
1765
  files.slice(0, visibleFileCount).forEach((f, i) => {
1723
1766
  const mb = formatBytesToMB(f.stat.size);
1724
1767
  const row = 12 + i;
1725
- const filename = f.name.length > nameColW
1726
- ? `${f.name.slice(0, Math.max(0, nameColW - 1))}…`
1727
- : f.name;
1768
+ const filename = fitTextToCellWidth(f.name, nameColW);
1728
1769
  scr.putStr(row, x + 3, String(i + 1).padEnd(numberColW), ANSI.fgWhite);
1729
- scr.putStr(row, x + 3 + numberColW + 1, filename.slice(0, nameColW).padEnd(nameColW), ANSI.fgWhite);
1730
- scr.putStr(row, x + 3 + formatX, f.ext.slice(1, formatColW + 1).padEnd(formatColW), ANSI.fgWhite);
1731
- scr.putStr(row, x + 3 + sizeX, mb.slice(0, sizeColW).padStart(sizeColW), ANSI.fgWhite);
1770
+ scr.putStrWithin(row, x + 3 + numberColW + 1, filename, nameColW, ANSI.fgWhite);
1771
+ scr.putStrWithin(row, x + 3 + formatX, f.ext.slice(1, formatColW + 1).padEnd(formatColW), formatColW, ANSI.fgWhite);
1772
+ scr.putStrWithin(row, x + 3 + sizeX, mb.slice(0, sizeColW).padStart(sizeColW), sizeColW, ANSI.fgWhite);
1732
1773
  });
1733
1774
  }
1734
1775
  }
@@ -1965,7 +2006,6 @@ class EvaNodeTUI {
1965
2006
  }
1966
2007
  if (data === '\x15') {
1967
2008
  this.modal.value = '';
1968
- this.modal.cursor = 0;
1969
2009
  this.render();
1970
2010
  return;
1971
2011
  }
@@ -1990,12 +2030,24 @@ class EvaNodeTUI {
1990
2030
  const mx = parseInt(mouseMatch[2], 10) - 1;
1991
2031
  const my = parseInt(mouseMatch[3], 10) - 1;
1992
2032
  const isPress = mouseMatch[4] === 'M';
2033
+ if (this.activeTab === 2 && (btn === 64 || btn === 65)) {
2034
+ const viewport = this.queueViewport;
2035
+ if (viewport && mx >= viewport.x && mx < viewport.right &&
2036
+ my >= viewport.top && my < viewport.top + viewport.rows) {
2037
+ const maxOffset = Math.max(0, this.manager.items.length - viewport.rows);
2038
+ this.queueScrollOffset = Math.max(0, Math.min(maxOffset, this.queueScrollOffset + (btn === 64 ? -1 : 1)));
2039
+ this.selectedQueueIdx = this.queueScrollOffset;
2040
+ this.selectedQueueItem = this.manager.items[this.selectedQueueIdx] || null;
2041
+ this.render();
2042
+ }
2043
+ return;
2044
+ }
1993
2045
  if (isPress && btn === 0) {
1994
2046
  const hit = this.hitboxes.find((h) =>
1995
2047
  mx >= h.x1 && mx <= h.x2 && (h.y === my || (h.nav && Math.abs(h.y - my) === 1))
1996
2048
  );
1997
2049
  if (hit) {
1998
- this.focusId = hit.id;
2050
+ if (hit.id !== 'q_scroll') this.focusId = hit.id;
1999
2051
  hit.action();
2000
2052
  }
2001
2053
  }
@@ -2142,6 +2194,7 @@ module.exports = {
2142
2194
  detectSource,
2143
2195
  isYouTubePlaylistUrl,
2144
2196
  playlistEntryUrl,
2197
+ ScreenBuffer,
2145
2198
  DownloadManager,
2146
2199
  formatBytesToMB,
2147
2200
  fitTextToCellWidth,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eva-md",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "description": "EVA Media Downloader CLI",
5
5
  "main": "eva.js",
6
6
  "bin": {