@havocrao/picktui 0.1.0 → 0.1.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.
- package/README.md +17 -14
- package/dist/cjs/cand.js +52 -0
- package/dist/cjs/config.js +19 -0
- package/dist/cjs/confirm.js +93 -16
- package/dist/cjs/filter.js +294 -31
- package/dist/cjs/history.js +105 -11
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/model.js +343 -0
- package/dist/cjs/toml.js +222 -0
- package/dist/cjs/tty.js +221 -0
- package/dist/cjs/tui.js +464 -46
- package/dist/esm/cand.js +47 -0
- package/dist/esm/config.js +16 -0
- package/dist/esm/confirm.js +87 -16
- package/dist/esm/filter.js +283 -31
- package/dist/esm/history.js +100 -11
- package/dist/esm/index.js +2 -1
- package/dist/esm/model.js +332 -0
- package/dist/esm/toml.js +216 -0
- package/dist/esm/tty.js +212 -0
- package/dist/esm/tui.js +461 -46
- package/dist/types/cand.d.ts +17 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/confirm.d.ts +13 -4
- package/dist/types/filter.d.ts +41 -9
- package/dist/types/history.d.ts +13 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/model.d.ts +48 -0
- package/dist/types/toml.d.ts +24 -0
- package/dist/types/tty.d.ts +42 -0
- package/dist/types/tui.d.ts +21 -9
- package/dist/types/types.d.ts +0 -7
- package/package.json +4 -4
package/dist/cjs/history.js
CHANGED
|
@@ -1,23 +1,117 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LEGACY_HISTORY_FILE = exports.HISTORY_FILE = void 0;
|
|
4
|
+
exports.historyPath = historyPath;
|
|
5
|
+
exports.loadPickHistory = loadPickHistory;
|
|
6
|
+
exports.lastPick = lastPick;
|
|
7
|
+
exports.savePick = savePick;
|
|
3
8
|
exports.histGet = histGet;
|
|
4
9
|
exports.histSet = histSet;
|
|
5
10
|
/**
|
|
6
|
-
* history —
|
|
11
|
+
* history — 选择记忆:记住每个选择器/歧义点上次选中的候选(history.toml)。
|
|
7
12
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
13
|
+
* 文件格式与 Go 引擎 BurntSushi/toml 输出完全兼容(同一数据目录共用):
|
|
14
|
+
*
|
|
15
|
+
* # picktui history — last selection per key (auto-managed)
|
|
16
|
+
* ["git p"]
|
|
17
|
+
* last = "pull"
|
|
18
|
+
*
|
|
19
|
+
* 曾用名 picks.toml 仍会被自动迁移到新位置(与引擎行为一致)。
|
|
20
|
+
* 文件不存在或损坏 → 空记录(记忆只是锦上添花,静默容错)。
|
|
10
21
|
*/
|
|
11
|
-
const
|
|
22
|
+
const node_fs_1 = require("node:fs");
|
|
23
|
+
const node_path_1 = require("node:path");
|
|
24
|
+
const config_js_1 = require("./config.js");
|
|
25
|
+
const toml_js_1 = require("./toml.js");
|
|
26
|
+
const types_js_1 = require("./types.js");
|
|
27
|
+
/** 选择记忆文件名;曾用名 picks.toml 因易与 pick 命令混淆而弃用。 */
|
|
28
|
+
exports.HISTORY_FILE = 'history.toml';
|
|
29
|
+
/** 旧版文件名,仅用于首次读取时自动迁移。 */
|
|
30
|
+
exports.LEGACY_HISTORY_FILE = 'picks.toml';
|
|
31
|
+
/** 注释头(对齐引擎输出,便于用户辨识文件来源)。 */
|
|
32
|
+
const HEADER = '# picktui history — last selection per key (auto-managed)';
|
|
33
|
+
/** 返回选择记忆文件路径。 */
|
|
34
|
+
function historyPath() {
|
|
35
|
+
return (0, node_path_1.join)((0, config_js_1.dataDir)(), exports.HISTORY_FILE);
|
|
36
|
+
}
|
|
37
|
+
/** history.toml 原始内容(label → { last }),与引擎同一份。 */
|
|
38
|
+
function loadPickHistory() {
|
|
39
|
+
const doc = loadHistoryToml();
|
|
40
|
+
const out = new Map();
|
|
41
|
+
for (const label of doc.keys()) {
|
|
42
|
+
const table = doc.get(label);
|
|
43
|
+
if (!table) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const last = table.get('last');
|
|
47
|
+
if (typeof last === 'string') {
|
|
48
|
+
out.set(label, last);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
/** 读取/迁移 history.toml:不存在时静默返回空 Map。 */
|
|
54
|
+
function loadHistoryToml() {
|
|
55
|
+
const p = historyPath();
|
|
56
|
+
if ((0, node_fs_1.existsSync)(p)) {
|
|
57
|
+
return parseHistoryFile((0, node_fs_1.readFileSync)(p, 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
const legacy = (0, node_path_1.join)((0, config_js_1.dataDir)(), exports.LEGACY_HISTORY_FILE);
|
|
60
|
+
if ((0, node_fs_1.existsSync)(legacy)) {
|
|
61
|
+
const raw = parseHistoryFile((0, node_fs_1.readFileSync)(legacy, 'utf8'));
|
|
62
|
+
try {
|
|
63
|
+
writeHistoryEntries(raw);
|
|
64
|
+
(0, node_fs_1.rmSync)(legacy, { force: true });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// 迁移写入失败不阻断读取——旧数据仍在内存中可用
|
|
68
|
+
}
|
|
69
|
+
return raw;
|
|
70
|
+
}
|
|
71
|
+
return new Map();
|
|
72
|
+
}
|
|
73
|
+
/** 解析 history.toml 文本;损坏时静默返回空(对齐引擎 LoadPickHistory)。 */
|
|
74
|
+
function parseHistoryFile(text) {
|
|
75
|
+
const doc = (0, toml_js_1.parseToml)(text);
|
|
76
|
+
if (doc === null) {
|
|
77
|
+
return new Map();
|
|
78
|
+
}
|
|
79
|
+
return doc;
|
|
80
|
+
}
|
|
81
|
+
/** 取回 label 上次选中的值;无记录返回 ""。 */
|
|
82
|
+
function lastPick(raw, label) {
|
|
83
|
+
return raw.get(label) ?? '';
|
|
84
|
+
}
|
|
85
|
+
/** 记录 label 的上次选中值(保留其它 label 的记忆后写回)。 */
|
|
86
|
+
function savePick(label, value) {
|
|
87
|
+
const raw = loadPickHistory();
|
|
88
|
+
raw.set(label, value);
|
|
89
|
+
writeHistoryEntries(raw);
|
|
90
|
+
}
|
|
91
|
+
/** 将记忆写回 history.toml(自动建目录,对齐引擎输出格式)。 */
|
|
92
|
+
function writeHistoryEntries(entries) {
|
|
93
|
+
const lines = [HEADER];
|
|
94
|
+
const labels = [...entries.keys()].sort();
|
|
95
|
+
for (const label of labels) {
|
|
96
|
+
lines.push(`[${(0, toml_js_1.keyLiteral)(label)}]`);
|
|
97
|
+
const table = entries.get(label);
|
|
98
|
+
const last = table instanceof Map ? table.get('last') : table;
|
|
99
|
+
lines.push(`last = "${(0, toml_js_1.escapeTomlString)(last ?? '')}"`);
|
|
100
|
+
}
|
|
101
|
+
const p = historyPath();
|
|
102
|
+
try {
|
|
103
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(p), { recursive: true });
|
|
104
|
+
(0, node_fs_1.writeFileSync)(p, lines.join('\n') + '\n', { mode: 0o644 });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
throw new types_js_1.PicktuiError(`写入选择记忆失败:${err instanceof Error ? err.message : String(err)}`, 1, '');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
12
110
|
/** 读取 label 上次选中的值;无记录返回 ""。 */
|
|
13
111
|
async function histGet(label) {
|
|
14
|
-
|
|
15
|
-
(0, engine_js_1.assertSuccess)(inv, 'picktui hist get');
|
|
16
|
-
const parsed = (0, engine_js_1.parseJSON)(inv.stdout, 'picktui hist get');
|
|
17
|
-
return parsed.last ?? '';
|
|
112
|
+
return lastPick(loadPickHistory(), label);
|
|
18
113
|
}
|
|
19
|
-
/** 记录 label
|
|
114
|
+
/** 记录 label 的上次选中值(失败抛 PicktuiError,含退出码 1)。 */
|
|
20
115
|
async function histSet(label, value) {
|
|
21
|
-
|
|
22
|
-
(0, engine_js_1.assertSuccess)(inv, 'picktui hist set');
|
|
116
|
+
savePick(label, value);
|
|
23
117
|
}
|
package/dist/cjs/index.js
CHANGED
|
@@ -14,14 +14,16 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.VERSION = void 0;
|
|
17
18
|
/**
|
|
18
19
|
* index — 聚合导出;亦支持按模块 import(tree-shakable):
|
|
19
20
|
* import { filter } from '@havocrao/picktui/filter'
|
|
20
21
|
* import { pick } from '@havocrao/picktui/tui'
|
|
21
22
|
* import { histGet } from '@havocrao/picktui/history'
|
|
22
23
|
*/
|
|
24
|
+
/** 本 TS 实现的语义版本(对齐 npm 包版本号)。 */
|
|
25
|
+
exports.VERSION = '0.1.0';
|
|
23
26
|
__exportStar(require("./types.js"), exports);
|
|
24
|
-
__exportStar(require("./engine.js"), exports);
|
|
25
27
|
__exportStar(require("./filter.js"), exports);
|
|
26
28
|
__exportStar(require("./tui.js"), exports);
|
|
27
29
|
__exportStar(require("./history.js"), exports);
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setColorEnabled = setColorEnabled;
|
|
4
|
+
exports.isColorEnabled = isColorEnabled;
|
|
5
|
+
exports.newModel = newModel;
|
|
6
|
+
exports.selectedValues = selectedValues;
|
|
7
|
+
exports.update = update;
|
|
8
|
+
exports.view = view;
|
|
9
|
+
exports.truncateWidth = truncateWidth;
|
|
10
|
+
exports.charWidth = charWidth;
|
|
11
|
+
exports.displayWidth = displayWidth;
|
|
12
|
+
/**
|
|
13
|
+
* model — 过滤选择器 TUI 状态机(纯逻辑,无 IO,可独立单测)。
|
|
14
|
+
*
|
|
15
|
+
* 对齐 Go 引擎 model:布局为 标题栏 / 分隔线 / 列表(视口滚动)/ 分隔线 /
|
|
16
|
+
* 输入行(闪烁光标)/ 状态栏;按键行为(循环移动、数字直选、多选勾选、
|
|
17
|
+
* ctrl+u/w 编辑)与引擎逐键一致。
|
|
18
|
+
*
|
|
19
|
+
* 本模块不接触终端:交互循环(tty.ts)只负责 按键 → update → view → 渲染。
|
|
20
|
+
*/
|
|
21
|
+
const filter_js_1 = require("./filter.js");
|
|
22
|
+
/** 气泡风格配色开关(NO_COLOR 环境变量时降级纯文本,保障脚本输出干净)。 */
|
|
23
|
+
let colorEnabled = true;
|
|
24
|
+
function setColorEnabled(v) {
|
|
25
|
+
colorEnabled = v;
|
|
26
|
+
}
|
|
27
|
+
function isColorEnabled() {
|
|
28
|
+
return colorEnabled;
|
|
29
|
+
}
|
|
30
|
+
/** 样式(对齐引擎 lipgloss 调色:120 亮绿 / 241 灰 / 36 青绿 / 213 粉 / 237 分隔)。 */
|
|
31
|
+
const STYLES = {
|
|
32
|
+
title: '\x1b[1;38;5;120m',
|
|
33
|
+
dim: '\x1b[38;5;241m',
|
|
34
|
+
ok: '\x1b[1;38;5;36m',
|
|
35
|
+
hl: '\x1b[38;5;213m',
|
|
36
|
+
cur: '\x1b[1;38;5;120m',
|
|
37
|
+
sep: '\x1b[38;5;237m',
|
|
38
|
+
prompt: '\x1b[1;38;5;120m',
|
|
39
|
+
reset: '\x1b[0m',
|
|
40
|
+
};
|
|
41
|
+
/** 包裹样式(颜色关闭时原样返回)。 */
|
|
42
|
+
function paint(name, s) {
|
|
43
|
+
if (!colorEnabled) {
|
|
44
|
+
return s;
|
|
45
|
+
}
|
|
46
|
+
return STYLES[name] + s + STYLES.reset;
|
|
47
|
+
}
|
|
48
|
+
/** 固定行数:标题(1) + 分隔线(1) + [列表] + 分隔线(1) + 输入(1) + 状态(1)。 */
|
|
49
|
+
const FIXED_LINES = 5;
|
|
50
|
+
function newModel(opts) {
|
|
51
|
+
let initial = opts.initial;
|
|
52
|
+
if (initial < 0 || initial >= opts.cands.length) {
|
|
53
|
+
initial = 0;
|
|
54
|
+
}
|
|
55
|
+
const m = {
|
|
56
|
+
opts,
|
|
57
|
+
filtered: [],
|
|
58
|
+
query: opts.query,
|
|
59
|
+
cursor: initial,
|
|
60
|
+
scrollY: 0,
|
|
61
|
+
height: 0,
|
|
62
|
+
width: 0,
|
|
63
|
+
phase: 0,
|
|
64
|
+
cancelled: false,
|
|
65
|
+
quit: false,
|
|
66
|
+
selected: new Set(opts.selected),
|
|
67
|
+
};
|
|
68
|
+
recompute(m);
|
|
69
|
+
return m;
|
|
70
|
+
}
|
|
71
|
+
/** 多选模式:按 Cands 顺序返回勾选的 Value 列表。 */
|
|
72
|
+
function selectedValues(m) {
|
|
73
|
+
if (m.selected.size === 0) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const out = [];
|
|
77
|
+
for (const c of m.opts.cands) {
|
|
78
|
+
if (m.selected.has(c.value)) {
|
|
79
|
+
out.push(c.value);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/** 根据当前 query 重新过滤,保持光标在有效范围内。 */
|
|
85
|
+
function recompute(m) {
|
|
86
|
+
m.filtered = (0, filter_js_1.filterCands)(m.opts.cands, m.query, m.opts.filterOpts);
|
|
87
|
+
if (m.cursor >= m.filtered.length) {
|
|
88
|
+
m.cursor = m.filtered.length - 1;
|
|
89
|
+
}
|
|
90
|
+
if (m.cursor < 0) {
|
|
91
|
+
m.cursor = 0;
|
|
92
|
+
}
|
|
93
|
+
ensureVisible(m);
|
|
94
|
+
}
|
|
95
|
+
/** 处理一个按键,返回新模型(不可变;变化时替换字段)。 */
|
|
96
|
+
function update(m, key) {
|
|
97
|
+
switch (key) {
|
|
98
|
+
case 'ctrl+c':
|
|
99
|
+
case 'esc':
|
|
100
|
+
return { ...m, cancelled: true, quit: true };
|
|
101
|
+
case 'enter':
|
|
102
|
+
if (m.filtered.length > 0) {
|
|
103
|
+
return { ...m, quit: true };
|
|
104
|
+
}
|
|
105
|
+
return m;
|
|
106
|
+
case 'space':
|
|
107
|
+
// 多选模式:无过滤输入时空格勾选/取消;否则空格作为过滤字符
|
|
108
|
+
if (m.opts.multi && m.query === '' && m.cursor < m.filtered.length) {
|
|
109
|
+
const v = m.filtered[m.cursor].value;
|
|
110
|
+
const next = new Set(m.selected);
|
|
111
|
+
if (next.has(v)) {
|
|
112
|
+
next.delete(v);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
next.add(v);
|
|
116
|
+
}
|
|
117
|
+
return { ...m, selected: next };
|
|
118
|
+
}
|
|
119
|
+
return appendQuery(m, ' ');
|
|
120
|
+
case 'up':
|
|
121
|
+
case 'k':
|
|
122
|
+
case 'ctrl+p':
|
|
123
|
+
return stepCursor(m, -1);
|
|
124
|
+
case 'down':
|
|
125
|
+
case 'j':
|
|
126
|
+
case 'ctrl+n':
|
|
127
|
+
case 'tab':
|
|
128
|
+
return stepCursor(m, 1);
|
|
129
|
+
case 'backspace':
|
|
130
|
+
if (m.query.length > 0) {
|
|
131
|
+
const runes = [...m.query];
|
|
132
|
+
runes.pop();
|
|
133
|
+
return recomputeQuery(m, runes.join(''));
|
|
134
|
+
}
|
|
135
|
+
return m;
|
|
136
|
+
case 'ctrl+u':
|
|
137
|
+
return recomputeQuery(m, '');
|
|
138
|
+
case 'ctrl+w':
|
|
139
|
+
return recomputeQuery(m, dropLastField(m.query));
|
|
140
|
+
default: {
|
|
141
|
+
// 统一为字符串处理(tty 键 'k'/'j' 等已在上方 case 匹配;数字是字符串)
|
|
142
|
+
const s = typeof key === 'object' ? key.value : key;
|
|
143
|
+
// 数字直选(菜单):无输入时 1-9 跳过逐行移动快速选中
|
|
144
|
+
if (m.opts.jumpKeys && m.query === '' && /^[1-9]$/.test(s)) {
|
|
145
|
+
const n = Number(s);
|
|
146
|
+
if (n - 1 < m.filtered.length) {
|
|
147
|
+
return { ...m, cursor: n - 1, quit: true };
|
|
148
|
+
}
|
|
149
|
+
return m;
|
|
150
|
+
}
|
|
151
|
+
if (s === '') {
|
|
152
|
+
return m; // 被忽略的键(如左右方向键)
|
|
153
|
+
}
|
|
154
|
+
return appendQuery(m, s);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function appendQuery(m, s) {
|
|
159
|
+
return recomputeQuery(m, m.query + s);
|
|
160
|
+
}
|
|
161
|
+
function recomputeQuery(m, q) {
|
|
162
|
+
const next = { ...m, query: q };
|
|
163
|
+
recompute(next);
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
/** 循环移动:首项按上键回到末项,末项按下键回到首项。 */
|
|
167
|
+
function stepCursor(m, delta) {
|
|
168
|
+
const n = m.filtered.length;
|
|
169
|
+
if (n <= 1) {
|
|
170
|
+
return m;
|
|
171
|
+
}
|
|
172
|
+
const cursor = (m.cursor + delta + n) % n;
|
|
173
|
+
const next = { ...m, cursor };
|
|
174
|
+
ensureVisible(next);
|
|
175
|
+
return next;
|
|
176
|
+
}
|
|
177
|
+
/** ctrl+w:去掉最后一词(与 Go strings.Fields 同语义)。 */
|
|
178
|
+
function dropLastField(q) {
|
|
179
|
+
const fields = q.trim().split(/\s+/).filter(Boolean);
|
|
180
|
+
if (fields.length === 0) {
|
|
181
|
+
return '';
|
|
182
|
+
}
|
|
183
|
+
return fields.slice(0, -1).join(' ');
|
|
184
|
+
}
|
|
185
|
+
/** 调整视口使光标可见(固定行数 5)。 */
|
|
186
|
+
function ensureVisible(m) {
|
|
187
|
+
const visibleRows = visibleRowsOf(m);
|
|
188
|
+
if (m.cursor < m.scrollY) {
|
|
189
|
+
m.scrollY = m.cursor;
|
|
190
|
+
}
|
|
191
|
+
if (m.cursor >= m.scrollY + visibleRows) {
|
|
192
|
+
m.scrollY = m.cursor - visibleRows + 1;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function visibleRowsOf(m) {
|
|
196
|
+
const rows = m.height - FIXED_LINES;
|
|
197
|
+
return rows > 0 ? rows : 10; // 尺寸未到达时的默认值
|
|
198
|
+
}
|
|
199
|
+
/** 渲染整屏画面(带 ANSI 样式)。 */
|
|
200
|
+
function view(m) {
|
|
201
|
+
const width = m.width > 0 ? m.width : 80;
|
|
202
|
+
const sep = paint('sep', '─'.repeat(width));
|
|
203
|
+
const out = [];
|
|
204
|
+
// 标题栏
|
|
205
|
+
let head = paint('title', m.opts.title);
|
|
206
|
+
if (m.opts.subtitle) {
|
|
207
|
+
head += paint('dim', ' · ' + m.opts.subtitle);
|
|
208
|
+
}
|
|
209
|
+
out.push(head, sep);
|
|
210
|
+
// 列表(视口区间)
|
|
211
|
+
const visibleRows = visibleRowsOf(m);
|
|
212
|
+
const end = Math.min(m.scrollY + visibleRows, m.filtered.length);
|
|
213
|
+
for (let i = m.scrollY; i < end; i++) {
|
|
214
|
+
const c = m.filtered[i];
|
|
215
|
+
let box = '';
|
|
216
|
+
if (m.opts.multi) {
|
|
217
|
+
box = m.selected.has(c.value) ? paint('ok', '[x] ') : '[ ] ';
|
|
218
|
+
}
|
|
219
|
+
if (i === m.cursor) {
|
|
220
|
+
// 光标行:整行绿色粗体(不再嵌套命中高亮,避免内层 reset 破坏外层颜色)
|
|
221
|
+
let line = ' ▸ ' + box + c.value;
|
|
222
|
+
if (c.desc) {
|
|
223
|
+
line += ' ' + c.desc;
|
|
224
|
+
}
|
|
225
|
+
const pad = width - displayWidth(line);
|
|
226
|
+
if (pad > 0) {
|
|
227
|
+
line += ' '.repeat(pad);
|
|
228
|
+
}
|
|
229
|
+
out.push(paint('cur', line));
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
out.push(' ' + box + renderCandidateLine(c, m.query, m.opts.filterOpts, width));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (m.filtered.length === 0) {
|
|
236
|
+
out.push(paint('dim', ' (no matches)'));
|
|
237
|
+
}
|
|
238
|
+
// 分隔线 + 输入行 + 状态栏
|
|
239
|
+
out.push(sep);
|
|
240
|
+
const cursorBlock = m.phase === 1 ? ' ' : '▏';
|
|
241
|
+
out.push(paint('prompt', '❯ ') + m.query + cursorBlock);
|
|
242
|
+
const count = `${m.filtered.length}/${m.opts.cands.length}`;
|
|
243
|
+
let status = ' ' + paint('ok', count);
|
|
244
|
+
if (m.opts.statusHint) {
|
|
245
|
+
status += paint('dim', ' · ' + m.opts.statusHint);
|
|
246
|
+
}
|
|
247
|
+
out.push(status);
|
|
248
|
+
return out.join('\n') + '\n';
|
|
249
|
+
}
|
|
250
|
+
/** 渲染候选行:value 命中高亮 + 灰色描述(按显示宽度截断)。 */
|
|
251
|
+
function renderCandidateLine(c, query, opts, width) {
|
|
252
|
+
const value = renderHighlighted(c.value, query, opts);
|
|
253
|
+
if (!c.desc) {
|
|
254
|
+
return value;
|
|
255
|
+
}
|
|
256
|
+
// 描述仅占 value 之后的剩余宽度(减 2 个分隔空格),太窄就不展示
|
|
257
|
+
const remaining = width - displayWidth(value) - 2;
|
|
258
|
+
if (remaining < 4) {
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
return value + ' ' + paint('dim', truncateWidth(c.desc, remaining));
|
|
262
|
+
}
|
|
263
|
+
/** 按显示宽度截断 s,超长末尾补 …。 */
|
|
264
|
+
function truncateWidth(s, maxWidth) {
|
|
265
|
+
if (maxWidth <= 1) {
|
|
266
|
+
return '…';
|
|
267
|
+
}
|
|
268
|
+
if (displayWidth(s) <= maxWidth) {
|
|
269
|
+
return s;
|
|
270
|
+
}
|
|
271
|
+
let out = '';
|
|
272
|
+
let w = 0;
|
|
273
|
+
for (const r of s) {
|
|
274
|
+
const rw = charWidth(r);
|
|
275
|
+
if (w + rw > maxWidth - 1) {
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
out += r;
|
|
279
|
+
w += rw;
|
|
280
|
+
}
|
|
281
|
+
return out + '…';
|
|
282
|
+
}
|
|
283
|
+
/** 渲染候选字符串,高亮匹配片段(粉色)。 */
|
|
284
|
+
function renderHighlighted(s, query, opts) {
|
|
285
|
+
const ranges = (0, filter_js_1.highlightRanges)(s, query, opts);
|
|
286
|
+
if (ranges.length === 0) {
|
|
287
|
+
return s;
|
|
288
|
+
}
|
|
289
|
+
const runes = [...s];
|
|
290
|
+
let out = '';
|
|
291
|
+
let pos = 0;
|
|
292
|
+
for (const [start, end] of ranges) {
|
|
293
|
+
if (pos < start) {
|
|
294
|
+
out += runes.slice(pos, start).join('');
|
|
295
|
+
}
|
|
296
|
+
out += paint('hl', runes.slice(start, end).join(''));
|
|
297
|
+
pos = end;
|
|
298
|
+
}
|
|
299
|
+
if (pos < runes.length) {
|
|
300
|
+
out += runes.slice(pos).join('');
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
/** 字符显示宽度(简化 wcwidth:CJK/全角=2,控制符=0,其余=1)。 */
|
|
305
|
+
function charWidth(ch) {
|
|
306
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
307
|
+
if (cp < 0x20 || cp === 0x7f) {
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
if (isWide(cp)) {
|
|
311
|
+
return 2;
|
|
312
|
+
}
|
|
313
|
+
return 1;
|
|
314
|
+
}
|
|
315
|
+
/** 字符串显示宽度。 */
|
|
316
|
+
function displayWidth(s) {
|
|
317
|
+
let w = 0;
|
|
318
|
+
for (const ch of s) {
|
|
319
|
+
w += charWidth(ch);
|
|
320
|
+
}
|
|
321
|
+
return w;
|
|
322
|
+
}
|
|
323
|
+
/** East Asian Wide / Fullwidth / emoji 等宽字符区间判定。 */
|
|
324
|
+
function isWide(cp) {
|
|
325
|
+
return ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
326
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK Radicals 等
|
|
327
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // 假名 / CJK 符号
|
|
328
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK 扩展 A
|
|
329
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK 统一表意
|
|
330
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // 彝文
|
|
331
|
+
(cp >= 0xa960 && cp <= 0xa97f) || // Hangul Jamo Extended-A
|
|
332
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul 音节
|
|
333
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK 兼容表意
|
|
334
|
+
(cp >= 0xfe10 && cp <= 0xfe19) || // 竖排形式
|
|
335
|
+
(cp >= 0xfe30 && cp <= 0xfe52) ||
|
|
336
|
+
(cp >= 0xfe54 && cp <= 0xfe66) ||
|
|
337
|
+
(cp >= 0xfe68 && cp <= 0xfe6b) ||
|
|
338
|
+
(cp >= 0xff00 && cp <= 0xff60) || // 全角形式
|
|
339
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
340
|
+
(cp >= 0x1f1e6 && cp <= 0x1f1ff) || // 区域指示符(旗帜)
|
|
341
|
+
(cp >= 0x1f300 && cp <= 0x1faff) // emoji
|
|
342
|
+
);
|
|
343
|
+
}
|