@lamemind/loom-deck 0.18.1 → 0.20.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/dist/cli.js +173 -31
- package/dist/task-edit.js +50 -12
- package/dist/tasks.js +1 -1
- package/dist/viewport.js +1 -1
- package/dist/width.js +96 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { render, Box, Text, useApp, useInput, useStdout } from 'ink';
|
|
4
4
|
import { useState, useEffect, useMemo, useRef } from 'react';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
6
7
|
import { statSync } from 'node:fs';
|
|
7
8
|
import { randomUUID } from 'node:crypto';
|
|
8
9
|
import { fileURLToPath } from 'node:url';
|
|
@@ -14,7 +15,7 @@ import { appendNote, appendPin, appendSessionRecord, appendTaskBinding, loadSess
|
|
|
14
15
|
import { assembleSessionList, firstSelectableId, moveSelection, rowIndexOf, rowLabel, selectedSession, stripProjectCore, } from './session-list.js';
|
|
15
16
|
import { launchLegend, loadIdentity, loadLaunch } from './config.js';
|
|
16
17
|
import { isCompact, layoutBudget, readerCapacity, searchListCapacity, searchPreviewCapacity, windowRange, } from './viewport.js';
|
|
17
|
-
import { cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
18
|
+
import { caretWindow, cut, sanitize, termWidth, wrapLines, wrapWithOffsets, } from './width.js';
|
|
18
19
|
import { applyView, cycleSort, describeSort, priName, progName, toggleHidden, PRI_ENTRIES, PROG_ENTRIES, } from './view.js';
|
|
19
20
|
import { initialDetail, progressText, writeTaskEdit, PRI_GLYPH, PRI_LABEL, PROG_GLYPH, } from './task-edit.js';
|
|
20
21
|
import { loadView, saveView, viewFilePath } from './view-store.js';
|
|
@@ -29,6 +30,36 @@ const MAX_SESSIONS = 30;
|
|
|
29
30
|
// meta "spot" (sentinella) che raccoglie le sessioni NON legate ad alcuna task.
|
|
30
31
|
// La selezione nel Tasks pane è il "padre"; il Sessions pane mostra i suoi figli.
|
|
31
32
|
const SPOT = Symbol('spot');
|
|
33
|
+
const EDIT_ROWS = 4;
|
|
34
|
+
/** Le righe del modale edit che sono campi di TESTO (il resto è scelta ←→). */
|
|
35
|
+
function isTextRow(r) {
|
|
36
|
+
return r === 2 || r === 3;
|
|
37
|
+
}
|
|
38
|
+
/** Chiave della bozza scritta dalla riga di testo `r`. */
|
|
39
|
+
function editField(r) {
|
|
40
|
+
return r === 2 ? 'detail' : 'title';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Lunghezza in CODE POINT. Il caret ci indicizza sopra: `.length` conterebbe le
|
|
44
|
+
* code unit UTF-16 e un'emoji nel titolo varrebbe 2 posizioni, cioè un cursore
|
|
45
|
+
* che si ferma a metà glifo e un `slice` che lo spezza in due surrogati.
|
|
46
|
+
*/
|
|
47
|
+
function cpLen(s) {
|
|
48
|
+
return [...s].length;
|
|
49
|
+
}
|
|
50
|
+
/** Inserisce `ins` alla posizione `at` (code point). */
|
|
51
|
+
function insertAt(s, at, ins) {
|
|
52
|
+
const cp = [...s];
|
|
53
|
+
return cp.slice(0, at).join('') + ins + cp.slice(at).join('');
|
|
54
|
+
}
|
|
55
|
+
/** Toglie il code point in posizione `at`; fuori range = stringa invariata. */
|
|
56
|
+
function removeAt(s, at) {
|
|
57
|
+
const cp = [...s];
|
|
58
|
+
if (at < 0 || at >= cp.length)
|
|
59
|
+
return s;
|
|
60
|
+
cp.splice(at, 1);
|
|
61
|
+
return cp.join('');
|
|
62
|
+
}
|
|
32
63
|
// Modale sort a grammatica libera: un tasto per chiave, pressioni successive
|
|
33
64
|
// ciclano asc → desc → fuori dalla chain.
|
|
34
65
|
const SORT_TASTI = { p: 'pri', s: 'prog', i: 'id' };
|
|
@@ -61,11 +92,36 @@ const EDIT_PROG = ['todo', 'wip', 'done', 'locked'];
|
|
|
61
92
|
function isDone(prog) {
|
|
62
93
|
return prog.includes('✔');
|
|
63
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Freno agli effetti VERSO L'ESTERNO (tab Ptyxis, sessioni Claude, git commit).
|
|
97
|
+
*
|
|
98
|
+
* Il gate di larghezza avvia il deck vero in uno pseudo-terminale e gli manda
|
|
99
|
+
* tasti — e in questa TUI un tasto è un'azione: `⏎` su una riga sessione apre
|
|
100
|
+
* una tab Ptyxis, `t` un terminale, `⏎` nel modale edit committa. Ogni run dei
|
|
101
|
+
* test apriva quindi finestre reali sulla macchina di chi li lanciava, in
|
|
102
|
+
* qualunque progetto avesse in focus.
|
|
103
|
+
*
|
|
104
|
+
* Il gate va tenuto sul deck VERO (è tutto il suo valore: misura il frame che
|
|
105
|
+
* VTE disegna davvero), quindi il freno sta qui: `LOOM_DECK_NO_SPAWN=1` fa
|
|
106
|
+
* restituire un figlio finto e inerte invece di lanciare il processo. Non è un
|
|
107
|
+
* mock del comportamento — l'azione semplicemente non avviene, e il frame che il
|
|
108
|
+
* test misura resta identico.
|
|
109
|
+
*/
|
|
110
|
+
const NO_SPAWN = process.env.LOOM_DECK_NO_SPAWN === '1';
|
|
111
|
+
function spawnOut(cmd, args, opts) {
|
|
112
|
+
if (!NO_SPAWN)
|
|
113
|
+
return spawn(cmd, args, opts);
|
|
114
|
+
// Figlio inerte: emette nulla, quindi i `.on('error'|'close')` dei chiamanti
|
|
115
|
+
// restano appesi senza mai scattare — che è esattamente "non è successo niente".
|
|
116
|
+
const fake = new EventEmitter();
|
|
117
|
+
fake.unref = () => fake;
|
|
118
|
+
return fake;
|
|
119
|
+
}
|
|
64
120
|
// Spawn detached: il deck spawna ma NON contiene la sessione (la possiede
|
|
65
121
|
// ptyxis-agent). unref + stdio ignore → ritorna subito, la TUI resta viva.
|
|
66
122
|
// sessionId pinnato (T27) → il binding sidecar è deterministico allo spawn.
|
|
67
123
|
function spawnDeck(id, cwd, sessionId) {
|
|
68
|
-
const child =
|
|
124
|
+
const child = spawnOut(DECK_RUN, [id, '--session-id', sessionId], {
|
|
69
125
|
cwd,
|
|
70
126
|
detached: true,
|
|
71
127
|
stdio: 'ignore',
|
|
@@ -81,7 +137,7 @@ function spawnDeck(id, cwd, sessionId) {
|
|
|
81
137
|
// continuarla, non iniettarle un messaggio (lo salta deck-run).
|
|
82
138
|
function spawnDeckResume(taskId, cwd, sessionId) {
|
|
83
139
|
const args = taskId ? [taskId, '--resume', sessionId] : ['--no-task', '--resume', sessionId];
|
|
84
|
-
const child =
|
|
140
|
+
const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
|
|
85
141
|
child.unref();
|
|
86
142
|
return child;
|
|
87
143
|
}
|
|
@@ -102,7 +158,7 @@ function spawnDeckFork(taskId, cwd, originId, newId) {
|
|
|
102
158
|
'--session-id',
|
|
103
159
|
newId,
|
|
104
160
|
];
|
|
105
|
-
const child =
|
|
161
|
+
const child = spawnOut(DECK_RUN, args, { cwd, detached: true, stdio: 'ignore' });
|
|
106
162
|
child.unref();
|
|
107
163
|
return child;
|
|
108
164
|
}
|
|
@@ -113,7 +169,7 @@ function spawnDeckFork(taskId, cwd, originId, newId) {
|
|
|
113
169
|
// sporcherebbe il percorso bound. Il titolo tab resta la label loom — lo mette
|
|
114
170
|
// deck-run, perché il match compass è window-level e non sa nulla di task.
|
|
115
171
|
function spawnClaudeEmpty(cwd) {
|
|
116
|
-
const child =
|
|
172
|
+
const child = spawnOut(DECK_RUN, ['--no-task'], {
|
|
117
173
|
cwd,
|
|
118
174
|
detached: true,
|
|
119
175
|
stdio: 'ignore',
|
|
@@ -130,7 +186,7 @@ function spawnClaudeEmpty(cwd) {
|
|
|
130
186
|
// esplicito in project-config-architecture.md). La project root arriva via cwd,
|
|
131
187
|
// non interpolata nella stringa.
|
|
132
188
|
function runLaunch(entry, cwd) {
|
|
133
|
-
const child =
|
|
189
|
+
const child = spawnOut('bash', ['-lic', entry.command], {
|
|
134
190
|
cwd,
|
|
135
191
|
detached: true,
|
|
136
192
|
stdio: 'ignore',
|
|
@@ -150,7 +206,7 @@ function runLaunch(entry, cwd) {
|
|
|
150
206
|
// dal radar finché quella tab è in primo piano).
|
|
151
207
|
function spawnTerminal(cwd, title) {
|
|
152
208
|
const args = title ? ['--tab', '-T', title, '-d', cwd] : ['--tab', '-d', cwd];
|
|
153
|
-
const child =
|
|
209
|
+
const child = spawnOut('ptyxis', args, { cwd, detached: true, stdio: 'ignore' });
|
|
154
210
|
child.unref();
|
|
155
211
|
return child;
|
|
156
212
|
}
|
|
@@ -167,7 +223,7 @@ const CLAUDE_CMD = process.env.LOOM_DECK_CLAUDE_CMD ?? 'claude';
|
|
|
167
223
|
// completa commit+push da sé; stdout in pipe SOLO per leggere il result event.
|
|
168
224
|
// Il prompt viaggia come singolo argv (no shell) → nessuna injection dal testo utente.
|
|
169
225
|
function spawnCreateTask(text, cwd, sessionId, onResult) {
|
|
170
|
-
const child =
|
|
226
|
+
const child = spawnOut(CLAUDE_CMD, [
|
|
171
227
|
'-p',
|
|
172
228
|
'--output-format',
|
|
173
229
|
'stream-json',
|
|
@@ -210,7 +266,7 @@ function spawnCreateTask(text, cwd, sessionId, onResult) {
|
|
|
210
266
|
// veloce e il suo esito va riportato nella nota. stderr raccolto per dire perché
|
|
211
267
|
// ha fallito (identità git assente, hook che rifiuta, …) invece di un generico ⚠.
|
|
212
268
|
function commitTaskEdit(cwd, paths, message, onResult) {
|
|
213
|
-
const child =
|
|
269
|
+
const child = spawnOut('git', ['commit', '-m', message, '--', ...paths], {
|
|
214
270
|
cwd,
|
|
215
271
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
216
272
|
});
|
|
@@ -623,6 +679,12 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
623
679
|
// da default. La priorità arriva dal glifo di tasks.md (già in `selTask`), lo
|
|
624
680
|
// stato dal suo glifo Prog; il progresso arbitrario dal campo `Progress` del
|
|
625
681
|
// task file — ma solo se è davvero custom (vedi `initialDetail`).
|
|
682
|
+
//
|
|
683
|
+
// Il titolo si semina dalla riga di tasks.md e non dall'H1 del task file per
|
|
684
|
+
// due ragioni: è la fonte che esiste SEMPRE (un task file può mancare), ed è
|
|
685
|
+
// il testo che l'utente sta guardando in lista quando preme `E`. Grezzo
|
|
686
|
+
// (`rawDesc`), non sanificato: rimandare a disco la forma sanificata
|
|
687
|
+
// riscriverebbe i glifi anche senza toccare il campo.
|
|
626
688
|
function openEdit() {
|
|
627
689
|
if (!selTask)
|
|
628
690
|
return;
|
|
@@ -631,6 +693,10 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
631
693
|
pri: priName(selTask.pri) ?? 'med',
|
|
632
694
|
prog,
|
|
633
695
|
detail: initialDetail(detail?.fields['Progress'] ?? '', prog),
|
|
696
|
+
title: selTask.rawDesc,
|
|
697
|
+
// Si apre sulla riga 0 (priorità), che non è un campo di testo: il caret
|
|
698
|
+
// prende la sua posizione entrando in una riga di testo con ↑↓.
|
|
699
|
+
caret: 0,
|
|
634
700
|
});
|
|
635
701
|
setEditRow(0);
|
|
636
702
|
setNote('');
|
|
@@ -648,9 +714,23 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
648
714
|
setEdit(null);
|
|
649
715
|
if (!task || !draft)
|
|
650
716
|
return;
|
|
717
|
+
// Il titolo si scrive solo se è CAMBIATO davvero: rimandarlo identico
|
|
718
|
+
// riscriverebbe comunque la cella (collassando spazi ed escape) e sporcherebbe
|
|
719
|
+
// il diff di una riga per un edit di sola priorità. Vuoto → scartato: una
|
|
720
|
+
// task senza descrizione in overview non è più riconoscibile.
|
|
721
|
+
const title = draft.title.trim();
|
|
722
|
+
const titleChanged = title.length > 0 && title !== task.rawDesc.trim();
|
|
651
723
|
let res;
|
|
652
724
|
try {
|
|
653
|
-
res = writeTaskEdit({
|
|
725
|
+
res = writeTaskEdit({
|
|
726
|
+
tasksPath,
|
|
727
|
+
tasksDir,
|
|
728
|
+
id: task.id,
|
|
729
|
+
pri: draft.pri,
|
|
730
|
+
prog: draft.prog,
|
|
731
|
+
detail: draft.detail,
|
|
732
|
+
title: titleChanged ? title : undefined,
|
|
733
|
+
});
|
|
654
734
|
}
|
|
655
735
|
catch (e) {
|
|
656
736
|
setNote(`⚠ ${task.id}: scrittura fallita (${e.message})`);
|
|
@@ -660,9 +740,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
660
740
|
setNote(`⚠ ${task.id}: nessun campo aggiornabile (riga o task file assenti)`);
|
|
661
741
|
return;
|
|
662
742
|
}
|
|
663
|
-
const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}`;
|
|
743
|
+
const summary = `${PRI_GLYPH[draft.pri]} ${PRI_LABEL[draft.pri]} · ${res.progress}${titleChanged ? ` · "${cut(sanitize(title), 32)}"` : ''}`;
|
|
664
744
|
setNote(`⏳ ${task.id} → ${summary} · commit…`);
|
|
665
|
-
commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}`, (ok, err) => {
|
|
745
|
+
commitTaskEdit(cwd, res.paths, `chore(${task.id}): pri ${PRI_LABEL[draft.pri]} · stato ${res.progress}${titleChanged ? ' · titolo' : ''}`, (ok, err) => {
|
|
666
746
|
setNote(ok ? `✔ ${task.id} → ${summary} · committato` : `⚠ ${task.id} salvato, commit fallito: ${err}`);
|
|
667
747
|
});
|
|
668
748
|
}
|
|
@@ -961,9 +1041,9 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
961
1041
|
}
|
|
962
1042
|
return;
|
|
963
1043
|
}
|
|
964
|
-
// T41 — modale edit: griglia a
|
|
965
|
-
// (←→ scorre),
|
|
966
|
-
//
|
|
1044
|
+
// T41 — modale edit: griglia a 4 righe. Righe 0/1 = scelta a valore singolo
|
|
1045
|
+
// (←→ scorre), righe 2/3 = testo libero (ogni carattere stampabile entra nel
|
|
1046
|
+
// campo). Come gli altri modali cattura tutto: `esc` annulla senza
|
|
967
1047
|
// scrivere né uscire dal deck.
|
|
968
1048
|
if (mode === 'edit') {
|
|
969
1049
|
if (key.escape) {
|
|
@@ -974,13 +1054,15 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
974
1054
|
else if (key.return) {
|
|
975
1055
|
submitEdit();
|
|
976
1056
|
}
|
|
977
|
-
else if (key.upArrow) {
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
1057
|
+
else if (key.upArrow || key.downArrow) {
|
|
1058
|
+
const next = ((editRow + EDIT_ROWS + (key.upArrow ? -1 : 1)) % EDIT_ROWS);
|
|
1059
|
+
setEditRow(next);
|
|
1060
|
+
// Il caret segue la riga attiva e atterra in CODA al nuovo campo: è la
|
|
1061
|
+
// posizione da cui si continua a scrivere, ed è anche l'unica che non
|
|
1062
|
+
// dipende da dove stava il cursore nel campo precedente.
|
|
1063
|
+
setEdit((e) => (e && isTextRow(next) ? { ...e, caret: cpLen(e[editField(next)]) } : e));
|
|
1064
|
+
}
|
|
1065
|
+
else if ((key.leftArrow || key.rightArrow) && !isTextRow(editRow)) {
|
|
984
1066
|
const d = key.leftArrow ? -1 : 1;
|
|
985
1067
|
// Scorrimento CICLICO (wrap) e non clampato: le liste sono di 3-4 voci,
|
|
986
1068
|
// arrivare in fondo e ripartire costa meno di invertire direzione.
|
|
@@ -993,12 +1075,52 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
993
1075
|
: e);
|
|
994
1076
|
}
|
|
995
1077
|
}
|
|
996
|
-
else if (editRow
|
|
997
|
-
|
|
998
|
-
|
|
1078
|
+
else if (isTextRow(editRow)) {
|
|
1079
|
+
// Un solo ramo per i due campi di testo, la riga sceglie la chiave:
|
|
1080
|
+
// duplicarlo significherebbe tenere allineate a mano due copie della
|
|
1081
|
+
// stessa grammatica di input a ogni tasto aggiunto.
|
|
1082
|
+
const field = editField(editRow);
|
|
1083
|
+
if (key.ctrl) {
|
|
1084
|
+
// T54 — ramo CTRL ANTEPOSTO a quelli su carattere, come in modalità
|
|
1085
|
+
// normale: `^A` e `a` arrivano con lo stesso `input`, quindi senza
|
|
1086
|
+
// questa precedenza il `^A` finirebbe dentro il testo.
|
|
1087
|
+
//
|
|
1088
|
+
// `^A`/`^E` (convenzione readline) perché `Home`/`End` NON sono
|
|
1089
|
+
// esposte da `useInput`: arrivano come input vuoto, indistinguibili
|
|
1090
|
+
// da qualunque altro tasto senza nome.
|
|
1091
|
+
//
|
|
1092
|
+
// `^D` è il delete-forward, e anche qui il motivo è un limite di Ink:
|
|
1093
|
+
// il tasto Backspace fisico manda `\x7f` e il tasto Canc manda
|
|
1094
|
+
// `\x1b[3~`, ma `parseKeypress` li battezza ENTRAMBI `delete` e
|
|
1095
|
+
// svuota `input` — a valle sono lo stesso evento. `key.delete` va
|
|
1096
|
+
// quindi al backspace (il tasto che si usa davvero) e la
|
|
1097
|
+
// cancellazione in avanti prende il suo tasto readline.
|
|
1098
|
+
if (input === 'a')
|
|
1099
|
+
setEdit((e) => (e ? { ...e, caret: 0 } : e));
|
|
1100
|
+
else if (input === 'e')
|
|
1101
|
+
setEdit((e) => (e ? { ...e, caret: cpLen(e[field]) } : e));
|
|
1102
|
+
else if (input === 'd')
|
|
1103
|
+
setEdit((e) => (e ? { ...e, [field]: removeAt(e[field], e.caret) } : e));
|
|
999
1104
|
}
|
|
1000
|
-
else if (
|
|
1001
|
-
|
|
1105
|
+
else if (key.leftArrow || key.rightArrow) {
|
|
1106
|
+
// CLAMP agli estremi, non wrap: a inizio campo `←` non deve saltare in
|
|
1107
|
+
// fondo. Le liste di valori (righe 0/1) ciclano perché sono 3-4 voci;
|
|
1108
|
+
// un testo no — il salto sarebbe indistinguibile da uno sfarfallio.
|
|
1109
|
+
const d = key.leftArrow ? -1 : 1;
|
|
1110
|
+
setEdit((e) => e ? { ...e, caret: Math.max(0, Math.min(cpLen(e[field]), e.caret + d)) } : e);
|
|
1111
|
+
}
|
|
1112
|
+
else if (key.backspace || key.delete) {
|
|
1113
|
+
setEdit((e) => e ? { ...e, [field]: removeAt(e[field], e.caret - 1), caret: Math.max(0, e.caret - 1) } : e);
|
|
1114
|
+
}
|
|
1115
|
+
else if (input && !key.meta) {
|
|
1116
|
+
// `sanitizeTyped`: `useInput` consegna il CHUNK di stdin, quindi un
|
|
1117
|
+
// incollaggio porta dentro newline e byte di controllo — invisibili
|
|
1118
|
+
// nel campo ma contati da Ink nella larghezza della riga, e destinati
|
|
1119
|
+
// a finire tali e quali dentro tasks.md. Ed è per lo stesso motivo che
|
|
1120
|
+
// il caret avanza della LUNGHEZZA del chunk, non di uno: un incollaggio
|
|
1121
|
+
// entra tutto insieme.
|
|
1122
|
+
const ins = sanitizeTyped(input);
|
|
1123
|
+
setEdit((e) => e ? { ...e, [field]: insertAt(e[field], e.caret, ins), caret: e.caret + cpLen(ins) } : e);
|
|
1002
1124
|
}
|
|
1003
1125
|
}
|
|
1004
1126
|
return;
|
|
@@ -1285,7 +1407,7 @@ function Deck({ cwd, tasksPath, tasksDir }) {
|
|
|
1285
1407
|
if (budget.compact) {
|
|
1286
1408
|
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), _jsxs(Text, { dimColor: true, children: [' ', "\u00B7 ", viewTasks.length, " task \u00B7 sel ", selectedTaskId ?? 'spot', " \u00B7 terminale ", rows, "\u00D7", columns, ": troppo basso, allarga"] })] }));
|
|
1287
1409
|
}
|
|
1288
|
-
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7
|
|
1410
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "loom-deck" }), mode === 'create' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nuova task \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " crea \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'sort' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["sort \u00B7 ", _jsx(Text, { color: "yellow", children: "p" }), " pri ", _jsx(Text, { color: "yellow", children: "s" }), " stato", ' ', _jsx(Text, { color: "yellow", children: "i" }), " id (asc\u2192desc\u2192off) \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'filter' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["filtri \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193\u2190\u2192" }), " naviga \u00B7 ", _jsx(Text, { color: "yellow", children: "spazio" }), ' ', "mostra/nascondi \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " ok \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'note' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["nota conversazione \u00B7 ", _jsx(Text, { color: "yellow", children: "^U" }), " svuota \u00B7", ' ', _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva (vuoto = rimuove) \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : mode === 'edit' ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["edit \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2191\u2193" }), " campo \u00B7 ", _jsx(Text, { color: "yellow", children: "\u2190\u2192" }), " valore, o cursore sul testo \u00B7 ", _jsx(Text, { color: "yellow", children: "^A/^E" }), " inizio/fine \u00B7", ' ', _jsx(Text, { color: "yellow", children: "^D" }), " canc \u00B7 ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " salva+commit \u00B7", ' ', _jsx(Text, { color: "yellow", children: "esc" }), " annulla"] })) : (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u2191\u2193 naviga \u00B7 \u2190\u2192 pane \u00B7 \u23CE ", canSpawn ? 'spawn' : canResume ? 'resume' : '—', canResume ? ' · f fork' : '', canPin ? ' · p pin · N nota' : '', " \u00B7 ^F cerca \u00B7 C nuova \u00B7 E edit \u00B7 S sort \u00B7 F filtri \u00B7 w salva \u00B7 t term \u00B7 c claude \u00B7 q esci \u00B7 focus:", ' ', _jsx(Text, { color: "cyan", children: focus })] })), mode === 'normal' && launch.length > 0 ? (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["launch ", legend.shown, legend.overflow > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 +", legend.overflow, " fuori riga"] })) : null, legend.unreachable > 0 ? (_jsxs(Text, { color: "yellow", children: [" \u00B7 ", legend.unreachable, " oltre la 9\u00AA (non raggiungibili)"] })) : null] })) : null, mode === 'create' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "C \u203A " }), _jsx(Text, { children: draft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'note' ? (_jsxs(Box, { borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: "yellow", children: "\u270E \u203A " }), _jsx(Text, { children: noteDraft }), _jsx(Text, { inverse: true, children: " " })] })) : null, mode === 'sort' ? _jsx(SortModal, { sort: view.sort }) : null, mode === 'filter' ? _jsx(FilterModal, { view: view, cursor: filterCursor }) : null, mode === 'edit' && edit && selTask ? (_jsx(EditModal, { id: selTask.id, draft: edit, row: editRow, columns: columns })) : null, _jsxs(Box, { flexDirection: "row", marginTop: 1, children: [_jsx(TasksPane, { tasks: windowTasks, filtered: viewTasks.length, total: tasks.length, hidden: hiddenTasks, view: view, selected: selIndex, spotCount: spotCount, childCount: childCount, focused: focus === 'tasks', loadError: loadError, detail: detail, windowStart: taskWin.start, above: taskWin.start, below: viewTasks.length - taskWin.end, detailLines: budget.detailLines, columns: columns }), _jsx(SessionsPane, { parentLabel: parentLabel, isSpot: isSpot, rows: windowRows, total: assembled.pinnedCount + assembled.contextTotal, pinnedCount: assembled.pinnedCount, hidden: assembled.contextHidden, selectedId: selSessionId ?? undefined, focused: focus === 'sessions', above: sessionWin.start, below: sessionRows.length - sessionWin.end, detail: budget.sessionDetail ? selSessionObj : null, firstLines: budget.sessionFirstLines, lastLines: budget.sessionLastLines, columns: columns, forkOf: forkOf, sessionNotes: sessionNotes, projectCore: projectCore })] }), note ? _jsx(Text, { color: "green", wrap: "truncate-end", children: sanitize(note) }) : null] }));
|
|
1289
1411
|
}
|
|
1290
1412
|
const SORT_UI = { pri: 'pri', prog: 'stato', id: 'id' };
|
|
1291
1413
|
// Modali resi IN FLUSSO (come l'input box di create), non in overlay assoluto:
|
|
@@ -1307,13 +1429,33 @@ function FilterModal({ view, cursor }) {
|
|
|
1307
1429
|
return (_jsxs(Text, { inverse: here, color: on ? 'green' : 'gray', dimColor: !on, children: [' ', "[", on ? 'x' : ' ', "] ", sanitize(e.glyph)] }, e.name));
|
|
1308
1430
|
})] }, row.label)))] }));
|
|
1309
1431
|
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Campo di testo del modale edit: finestra ancorata al caret + cursore inverso
|
|
1434
|
+
* nella posizione REALE.
|
|
1435
|
+
*
|
|
1436
|
+
* Il cursore non è più uno spazio inverso appiccicato in coda ma la cella `at`
|
|
1437
|
+
* della finestra — cioè il carattere su cui il caret sta davvero. Fuori fuoco
|
|
1438
|
+
* (`focused` falso) il caret non si disegna e la finestra si ancora in fondo,
|
|
1439
|
+
* che è la vista utile per un campo che non si sta scrivendo.
|
|
1440
|
+
*/
|
|
1441
|
+
function EditTextField({ label, value, caret, focused, cols, }) {
|
|
1442
|
+
const win = caretWindow(value, focused ? caret : cpLen(value), cols);
|
|
1443
|
+
return (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: label }), _jsxs(Text, { children: [' ', sanitize(win.head)] }), focused ? _jsx(Text, { inverse: true, children: sanitize(win.at) }) : null, _jsx(Text, { children: sanitize(win.tail) })] }));
|
|
1444
|
+
}
|
|
1310
1445
|
// T41 — modale edit, in flusso come gli altri (spinge giù i pane invece di
|
|
1311
1446
|
// coprirli: la riga che stai modificando resta visibile sopra la lista).
|
|
1312
1447
|
// La riga di anteprima mostra il testo ESATTO che finirà nel campo `Progress`
|
|
1313
1448
|
// del task file — così il default (`✔️ Done at <oggi>`) non è una sorpresa.
|
|
1314
|
-
function EditModal({ id, draft, row }) {
|
|
1449
|
+
function EditModal({ id, draft, row, columns, }) {
|
|
1315
1450
|
const mark = (r) => (row === r ? CARET : CARET_OFF);
|
|
1316
|
-
|
|
1451
|
+
// Budget dei campi di testo, DERIVATO da `columns` (mai una costante): il box
|
|
1452
|
+
// del modale è ANNIDATO nella cornice del deck, quindi le cornici da scalare
|
|
1453
|
+
// sono due — root (bordo 2 + paddingX 2) e modale (bordo 2 + paddingX 2) — più
|
|
1454
|
+
// caret 2, etichetta 6, gap 2 e cursore 1. Totale 19.
|
|
1455
|
+
// Un titolo di tasks.md arriva a ~64 caratteri: senza taglio la riga va a capo
|
|
1456
|
+
// dentro il box, che si alza di una riga e sfonda il budget verticale (invariante ③).
|
|
1457
|
+
const fieldBudget = Math.max(8, columns - 19);
|
|
1458
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, marginTop: 1, children: [_jsxs(Text, { color: "yellow", children: ["E \u203A ", id, " \u00B7 titolo, priorit\u00E0 e stato"] }), _jsxs(Text, { children: [mark(0), _jsx(Text, { dimColor: true, children: "pri " }), EDIT_PRI.map((p) => (_jsxs(Text, { inverse: draft.pri === p, color: draft.pri === p ? 'green' : 'gray', children: [' ', sanitize(PRI_GLYPH[p]), " ", PRI_LABEL[p]] }, p)))] }), _jsxs(Text, { children: [mark(1), _jsx(Text, { dimColor: true, children: "stato " }), EDIT_PROG.map((p) => (_jsxs(Text, { inverse: draft.prog === p, color: draft.prog === p ? 'green' : 'gray', children: [' ', sanitize(PROG_GLYPH[p]), " ", p] }, p)))] }), _jsxs(Text, { children: [mark(2), _jsx(EditTextField, { label: "prog ", value: draft.detail, caret: draft.caret, focused: row === 2, cols: fieldBudget }), !draft.detail && row !== 2 ? _jsx(Text, { dimColor: true, children: "(default)" }) : null] }), _jsxs(Text, { children: [mark(3), _jsx(EditTextField, { label: "titolo", value: draft.title, caret: draft.caret, focused: row === 3, cols: fieldBudget })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: ["\u21B3 ", sanitize(progressText(draft.prog, draft.detail))] })] }));
|
|
1317
1459
|
}
|
|
1318
1460
|
// T52 — marcatore compatto del tipo di corpo sulla riga-occorrenza. Due
|
|
1319
1461
|
// caratteri ASCII e non un'emoji: con più toggle accesi la colonna deve
|
package/dist/task-edit.js
CHANGED
|
@@ -75,18 +75,27 @@ export function initialDetail(current, prog, date = today()) {
|
|
|
75
75
|
return stripProgGlyph(s);
|
|
76
76
|
}
|
|
77
77
|
/**
|
|
78
|
-
* Riscrive le celle Pri (col 2)
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
* restano i token grezzi originali: nessun
|
|
82
|
-
* di una riga. `ok:false` = id assente →
|
|
78
|
+
* Riscrive le celle Pri (col 2), Prog (col 4) e — se `desc` è passata — la
|
|
79
|
+
* descrizione (col 5) della riga `| Tnn | … |` in tasks.md. Solo la PRIMA riga
|
|
80
|
+
* con quell'id — l'overview è unica, un secondo match sarebbe un duplicato da
|
|
81
|
+
* non propagare. Le celle non toccate restano i token grezzi originali: nessun
|
|
82
|
+
* re-flow della tabella, il diff resta di una riga. `ok:false` = id assente →
|
|
83
|
+
* il chiamante non scrive nulla.
|
|
84
|
+
*
|
|
85
|
+
* `desc` undefined ≠ `desc` vuota: la prima non tocca la cella (chiamata
|
|
86
|
+
* legacy, solo pri/prog), la seconda la svuota davvero.
|
|
87
|
+
*
|
|
88
|
+
* La descrizione è l'ULTIMA colonna e può contenere `|` — che in una tabella
|
|
89
|
+
* markdown spezza la cella. Riscriverla significa quindi collassare tutte le
|
|
90
|
+
* celle da 5 fino alla penultima in una sola (`splice`), altrimenti i pezzi
|
|
91
|
+
* della vecchia descrizione resterebbero appesi in coda alla nuova.
|
|
83
92
|
*
|
|
84
93
|
* L'id deve rispettare `TASK_ID_RE` (importato da tasks.ts — stesso gate di
|
|
85
94
|
* `parseTasks`, non una copia): senza, un id arbitrario matcherebbe la riga di
|
|
86
95
|
* HEADER (`| ID | Pri | K | Prog |`) o quella di separatore, riscrivendone le
|
|
87
96
|
* celle e sfondando la tabella.
|
|
88
97
|
*/
|
|
89
|
-
export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
|
|
98
|
+
export function updateTasksMdRow(content, id, priGlyph, progGlyph, desc) {
|
|
90
99
|
if (!TASK_ID_RE.test(id))
|
|
91
100
|
return { content, ok: false };
|
|
92
101
|
let ok = false;
|
|
@@ -103,20 +112,49 @@ export function updateTasksMdRow(content, id, priGlyph, progGlyph) {
|
|
|
103
112
|
ok = true;
|
|
104
113
|
cells[2] = ` ${priGlyph} `;
|
|
105
114
|
cells[4] = ` ${progGlyph} `;
|
|
115
|
+
if (desc !== undefined) {
|
|
116
|
+
// Da cells[5] fino alla penultima: l'ultima è la coda dopo il `|` finale
|
|
117
|
+
// (stringa vuota su una riga ben formata) e va conservata, o la riga
|
|
118
|
+
// perderebbe il proprio bordo destro.
|
|
119
|
+
cells.splice(5, cells.length - 6, ` ${sanitizeCell(desc)} `);
|
|
120
|
+
}
|
|
106
121
|
return cells.join('|');
|
|
107
122
|
});
|
|
108
123
|
return { content: ok ? lines.join('\n') : content, ok };
|
|
109
124
|
}
|
|
110
125
|
/**
|
|
111
|
-
*
|
|
126
|
+
* Rende un testo scrivibile dentro una CELLA di tabella markdown. Due caratteri
|
|
127
|
+
* la romperebbero e non sono rappresentabili altrimenti su una riga sola:
|
|
128
|
+
* il `|` (chiude la cella) e l'a-capo (chiude la riga). Il primo va escapato,
|
|
129
|
+
* il secondo collassato a spazio. È l'unico posto dove il titolo viene toccato:
|
|
130
|
+
* nel task file, che è markdown libero, va scritto verbatim.
|
|
131
|
+
*/
|
|
132
|
+
function sanitizeCell(s) {
|
|
133
|
+
return s.replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Riscrive i bullet header `- **Priority**:` e `- **Progress**:` del task file,
|
|
137
|
+
* più — se `title` è passato — l'H1 di testa.
|
|
112
138
|
* First-match-wins per chiave, stessa regola di `parseTaskDetail`: se un campo
|
|
113
139
|
* ricompare nel body (residuo template) vince quello dell'header — così ciò che
|
|
114
140
|
* il deck mostra e ciò che scrive restano la stessa riga.
|
|
141
|
+
*
|
|
142
|
+
* L'H1 conserva il proprio CAPPELLO (`# Task: …` code, `# Doc Task: …` doc):
|
|
143
|
+
* `parseTaskDetail` lo strippa in lettura, quindi il titolo che arriva dal
|
|
144
|
+
* modale non ce l'ha e riscrivere la riga nuda perderebbe la categoria. Il
|
|
145
|
+
* prefisso si rilegge dalla riga esistente e si ri-antepone tale e quale — così
|
|
146
|
+
* una T resta `# Task:` e una D resta `# Doc Task:` senza doverlo sapere qui.
|
|
115
147
|
*/
|
|
116
|
-
export function updateTaskFileFields(content, priLabel, progress) {
|
|
148
|
+
export function updateTaskFileFields(content, priLabel, progress, title) {
|
|
117
149
|
let priDone = false;
|
|
118
150
|
let progDone = false;
|
|
151
|
+
let titleDone = false;
|
|
119
152
|
const lines = content.split('\n').map((line) => {
|
|
153
|
+
if (title !== undefined && !titleDone && /^#\s+/.test(line)) {
|
|
154
|
+
titleDone = true;
|
|
155
|
+
const prefix = line.match(/^#\s+((?:Doc\s+)?Task:\s*)/)?.[1] ?? '';
|
|
156
|
+
return `# ${prefix}${title.replace(/\s+/g, ' ').trim()}`;
|
|
157
|
+
}
|
|
120
158
|
if (!priDone && /^-\s*\*\*Priority\*\*:/.test(line)) {
|
|
121
159
|
priDone = true;
|
|
122
160
|
return `- **Priority**: ${priLabel}`;
|
|
@@ -127,7 +165,7 @@ export function updateTaskFileFields(content, priLabel, progress) {
|
|
|
127
165
|
}
|
|
128
166
|
return line;
|
|
129
167
|
});
|
|
130
|
-
const ok = priDone || progDone;
|
|
168
|
+
const ok = priDone || progDone || titleDone;
|
|
131
169
|
return { content: ok ? lines.join('\n') : content, ok };
|
|
132
170
|
}
|
|
133
171
|
/**
|
|
@@ -138,10 +176,10 @@ export function updateTaskFileFields(content, priLabel, progress) {
|
|
|
138
176
|
* si scrive ciò che esiste e il risultato dice cosa è stato toccato.
|
|
139
177
|
*/
|
|
140
178
|
export function writeTaskEdit(input) {
|
|
141
|
-
const { tasksPath, tasksDir, id, pri, prog, detail } = input;
|
|
179
|
+
const { tasksPath, tasksDir, id, pri, prog, detail, title } = input;
|
|
142
180
|
const progress = progressText(prog, detail);
|
|
143
181
|
const paths = [];
|
|
144
|
-
const row = updateTasksMdRow(readFileSync(tasksPath, 'utf8'), id, PRI_GLYPH[pri], PROG_GLYPH[prog]);
|
|
182
|
+
const row = updateTasksMdRow(readFileSync(tasksPath, 'utf8'), id, PRI_GLYPH[pri], PROG_GLYPH[prog], title);
|
|
145
183
|
if (row.ok) {
|
|
146
184
|
writeFileSync(tasksPath, row.content, 'utf8');
|
|
147
185
|
paths.push(tasksPath);
|
|
@@ -149,7 +187,7 @@ export function writeTaskEdit(input) {
|
|
|
149
187
|
let fileUpdated = false;
|
|
150
188
|
const taskFile = findTaskFile(tasksDir, id);
|
|
151
189
|
if (taskFile) {
|
|
152
|
-
const upd = updateTaskFileFields(readFileSync(taskFile, 'utf8'), PRI_LABEL[pri], progress);
|
|
190
|
+
const upd = updateTaskFileFields(readFileSync(taskFile, 'utf8'), PRI_LABEL[pri], progress, title);
|
|
153
191
|
if (upd.ok) {
|
|
154
192
|
writeFileSync(taskFile, upd.content, 'utf8');
|
|
155
193
|
paths.push(taskFile);
|
package/dist/tasks.js
CHANGED
|
@@ -52,7 +52,7 @@ export function parseTasks(content) {
|
|
|
52
52
|
// il glifo, e `task-edit` lo riscrive in tasks.md. Sanificarli qui
|
|
53
53
|
// renderebbe `✔` un `✅` anche su disco, cambiando il formato di famiglia.
|
|
54
54
|
// La sanificazione di quei due avviene al display (`displayProg`).
|
|
55
|
-
tasks.push({ id, pri, prog, desc: sanitize(desc) });
|
|
55
|
+
tasks.push({ id, pri, prog, desc: sanitize(desc), rawDesc: desc });
|
|
56
56
|
}
|
|
57
57
|
return tasks;
|
|
58
58
|
}
|
package/dist/viewport.js
CHANGED
|
@@ -41,7 +41,7 @@ export const MODAL_HEIGHT = {
|
|
|
41
41
|
note: 4, // T53 — gemello di create: marginTop + 2 bordi + 1 riga input
|
|
42
42
|
sort: 5, // marginTop + 2 bordi + titolo + 1 riga catena
|
|
43
43
|
filter: 6, // marginTop + 2 bordi + titolo + 2 righe (pri, stato)
|
|
44
|
-
edit:
|
|
44
|
+
edit: 9, // marginTop + 2 bordi + titolo + 4 campi (pri, stato, prog, titolo) + riga anteprima
|
|
45
45
|
// T52 — search e reader sono gli unici modali NON in flusso: sostituiscono i
|
|
46
46
|
// due pane invece di spingerli giù (una lista di occorrenze non entra in un
|
|
47
47
|
// box sopra il deck). Costo 0 nel budget dei pane perché quel budget non
|
package/dist/width.js
CHANGED
|
@@ -202,6 +202,102 @@ export function cut(s, cols) {
|
|
|
202
202
|
// informazione, tolgono una colonna di testo.
|
|
203
203
|
return out.trimEnd().replace(/…$/, '') + '…';
|
|
204
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Larghezza in colonne di UN carattere **dopo** `sanitize`.
|
|
207
|
+
*
|
|
208
|
+
* Misurare il carattere grezzo significa misurare qualcosa che nel frame non
|
|
209
|
+
* entra mai: la sanificazione può cambiarne la larghezza (`✔`, largo 1, diventa
|
|
210
|
+
* `✅`, largo 2 — invariante ①). Chi taglia su un budget deve contare le colonne
|
|
211
|
+
* che verranno DISEGNATE, altrimenti sbaglia di una colonna per ogni glifo
|
|
212
|
+
* sostituito. La cache di `fixChar` rende la doppia chiamata gratuita.
|
|
213
|
+
*/
|
|
214
|
+
export function cellWidth(ch) {
|
|
215
|
+
return termWidth(sanitize(ch));
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Porzione visibile di un campo di testo con un caret MOBILE, larga al più
|
|
219
|
+
* `cols` colonne.
|
|
220
|
+
*
|
|
221
|
+
* Perché non basta un taglio dalla coda: mostrare sempre le ultime `cols`
|
|
222
|
+
* colonne è corretto solo finché si scrive in fondo. Con un caret che si muove,
|
|
223
|
+
* appena rientra a sinistra del bordo visibile si finirebbe per digitare in un
|
|
224
|
+
* punto che non si vede — il testo deve scorrere DIETRO il cursore, non
|
|
225
|
+
* viceversa.
|
|
226
|
+
*
|
|
227
|
+
* Il caret è un indice per CODE POINT (`[...s]`), non per code unit: un'emoji
|
|
228
|
+
* nel titolo sono due code unit e muoversi per unità la spezzerebbe a metà.
|
|
229
|
+
*
|
|
230
|
+
* La finestra si espande ALTERNANDO i due lati, così il cursore resta al centro
|
|
231
|
+
* finché il testo lo consente: muoversi di un carattere fa scorrere il testo di
|
|
232
|
+
* uno, invece di far saltare la vista da un bordo all'altro. Le ellissi entrano
|
|
233
|
+
* nel budget e ne escono da sole — quando un lato tocca il bordo del testo la
|
|
234
|
+
* sua ellissi non serve più e la colonna liberata va all'altro lato.
|
|
235
|
+
*
|
|
236
|
+
* Restituisce i tre pezzi già pronti al render (non la sola stringa visibile):
|
|
237
|
+
* il cursore si disegna invertendo `at`, e ricavarlo tagliando `visible` a
|
|
238
|
+
* `cursorCol` vorrebbe dire rifare qui fuori lo stesso conteggio in colonne.
|
|
239
|
+
*/
|
|
240
|
+
export function caretWindow(text, caret, cols) {
|
|
241
|
+
const empty = { head: '', at: '', tail: '', cursorCol: 0 };
|
|
242
|
+
if (cols <= 0)
|
|
243
|
+
return empty;
|
|
244
|
+
const chars = [...text];
|
|
245
|
+
const c = Math.max(0, Math.min(caret, chars.length));
|
|
246
|
+
// Il caret a fine campo non ha un carattere sotto di sé: gliene si dà uno
|
|
247
|
+
// virtuale, così il cursore ha sempre una cella da invertire e la finestra ha
|
|
248
|
+
// sempre un centro. È lo spazio inverso che il modale disegnava in coda.
|
|
249
|
+
const cells = c === chars.length ? [...chars, ' '] : chars;
|
|
250
|
+
const len = cells.length;
|
|
251
|
+
const w = cells.map(cellWidth);
|
|
252
|
+
const cost = (s, e, tw) => tw + (s > 0 ? 1 : 0) + (e < len ? 1 : 0);
|
|
253
|
+
let start = c;
|
|
254
|
+
let end = c + 1;
|
|
255
|
+
let textW = w[c];
|
|
256
|
+
// Budget più stretto della sola cella del cursore: non c'è finestra da dare.
|
|
257
|
+
if (cost(start, end, textW) > cols)
|
|
258
|
+
return empty;
|
|
259
|
+
let goRight = true;
|
|
260
|
+
let stuckL = false;
|
|
261
|
+
let stuckR = false;
|
|
262
|
+
while (!stuckL || !stuckR) {
|
|
263
|
+
const canR = end < len && !stuckR;
|
|
264
|
+
const canL = start > 0 && !stuckL;
|
|
265
|
+
if (!canR && !canL)
|
|
266
|
+
break;
|
|
267
|
+
if (canR && (goRight || !canL)) {
|
|
268
|
+
const nw = textW + w[end];
|
|
269
|
+
if (cost(start, end + 1, nw) > cols)
|
|
270
|
+
stuckR = true;
|
|
271
|
+
else {
|
|
272
|
+
textW = nw;
|
|
273
|
+
end++;
|
|
274
|
+
// Arrivare in fondo toglie l'ellissi di coda: la colonna che si libera
|
|
275
|
+
// può rimettere in gioco il lato che si era fermato per un pelo.
|
|
276
|
+
if (end === len)
|
|
277
|
+
stuckL = false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const nw = textW + w[start - 1];
|
|
282
|
+
if (cost(start - 1, end, nw) > cols)
|
|
283
|
+
stuckL = true;
|
|
284
|
+
else {
|
|
285
|
+
textW = nw;
|
|
286
|
+
start--;
|
|
287
|
+
if (start === 0)
|
|
288
|
+
stuckR = false;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
goRight = !goRight;
|
|
292
|
+
}
|
|
293
|
+
const head = (start > 0 ? '…' : '') + cells.slice(start, c).join('');
|
|
294
|
+
return {
|
|
295
|
+
head,
|
|
296
|
+
at: cells[c],
|
|
297
|
+
tail: cells.slice(c + 1, end).join('') + (end < len ? '…' : ''),
|
|
298
|
+
cursorCol: termWidth(sanitize(head)),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
205
301
|
/**
|
|
206
302
|
* Indice sorgente raggiunto consumando al più `cols` colonne da `from`.
|
|
207
303
|
*
|
package/package.json
CHANGED