Skip to content

Commit 77af713

Browse files
“bingtao”Paperclip-Paperclip
andcommitted
feat(lib): add nlt-progress terminal progress helpers
- Add scripts/lib/nlt-progress.sh: human-readable sizes, TTY bar with ETA/rate, indeterminate mode when total unknown, curl download watcher - Ship via install.sh _nlt_cp_first to libexec lib - Add tests/progress_smoke.sh; document in README - Narrow .gitignore lib/ to /lib/ so scripts/lib stays versioned Co-Authored-By: Paperclip <noreply@paperclip.ing> Made-with: Cursor
1 parent 66073ad commit 77af713

5 files changed

Lines changed: 243 additions & 4 deletions

File tree

.gitignore

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ dist/
1010
downloads/
1111
eggs/
1212
.eggs/
13-
lib/
14-
lib64/
13+
/lib/
14+
/lib64/
1515
parts/
1616
sdist/
1717
var/

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,17 @@ export PATH="$HOME/.local/nltdeploy/bin:$PATH"
7777

7878
```bash
7979
bash tests/install_smoke.sh
80+
bash tests/progress_smoke.sh
8081
```
8182

83+
### 进度条库(`nlt-progress`
84+
85+
安装后与其他 lib 一并位于 **`${NLTDEPLOY_ROOT}/libexec/nltdeploy/lib/nlt-progress.sh`**(默认即 `~/.local/nltdeploy/libexec/nltdeploy/lib/nlt-progress.sh`)。开发时可直接 `source` 仓库内 **`scripts/lib/nlt-progress.sh`**
86+
87+
提供 **`nlt_pb_human_bytes`****`nlt_pb_render`** / **`nlt_pb_done`**(TTY 下一行进度条:百分比、已用/ETA、速率与 xfer 大小),以及 **`nlt_pb_curl_to_file <url> <dest> [total_bytes]`**(后台 `curl` 写文件时轮询本地大小并刷新进度;非 TTY 下默认降低日志噪声,可用 **`NLT_PB_LABEL`** 自定义标签)。依赖:Bash 3.2+、`awk``date`、可选 **`curl`**(仅下载辅助函数)。
88+
89+
设计说明见 [`docs/superpowers/specs/2026-04-15-war-23-progress-bar-design.md`](docs/superpowers/specs/2026-04-15-war-23-progress-bar-design.md)
90+
8291
### 命令对照表(安装后的 `bin``scripts/`
8392

8493
| 安装后的命令 | 对应原 scripts 用法 |
@@ -106,10 +115,12 @@ nltdeploy/
106115
├── examples/
107116
│ └── python_env_examples.md # 与 uv/Python 环境相关的用法示例(偏命令行工具向)
108117
├── tests/
109-
│ └── install_smoke.sh # 安装与 bin 包装冒烟测试
118+
│ ├── install_smoke.sh # 安装与 bin 包装冒烟测试
119+
│ └── progress_smoke.sh # nlt-progress 语法与 source 冒烟
110120
├── scripts/
111121
│ ├── lib/
112-
│ │ └── nlt-common.sh # _nlt_ensure_gum 等公共片段(各 setup 脚本 source)
122+
│ │ ├── nlt-common.sh # _nlt_ensure_gum 等公共片段(各 setup 脚本 source)
123+
│ │ └── nlt-progress.sh # 可 source 的终端进度条与 curl 下载监视(可选)
113124
│ ├── tools/ # 工具 / 环境类(非长期服务进程)
114125
│ │ ├── pip-sources/
115126
│ │ │ ├── setup.sh

install.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,9 @@ do_install_or_update() {
309309
"${SCRIPTS}/lib/nlt-common.sh" \
310310
"${SCRIPTS}/_lib/nlt-common.sh"
311311

312+
_nlt_cp_first "${LIBEXEC}/lib/nlt-progress.sh" \
313+
"${SCRIPTS}/lib/nlt-progress.sh"
314+
312315
_nlt_cp_first "${LIBEXEC}/pip-sources/setup.sh" \
313316
"${SCRIPTS}/tools/pip-sources/setup.sh" \
314317
"${SCRIPTS}/pip-sources/setup.sh"

scripts/lib/nlt-progress.sh

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/env bash
2+
# nlt-progress:可复用终端进度条(macOS + Linux)。由其他脚本 source。
3+
[[ -n "${_NLT_PROGRESS_LOADED:-}" ]] && return 0
4+
_NLT_PROGRESS_LOADED=1
5+
6+
_nlt_file_size() {
7+
local f="$1"
8+
[[ -f "$f" ]] || { echo 0; return 0; }
9+
if stat -f%z "$f" >/dev/null 2>&1; then
10+
stat -f%z "$f"
11+
else
12+
stat -c%s "$f" 2>/dev/null || echo 0
13+
fi
14+
}
15+
16+
_nlt_pb_now_s() {
17+
date +%s
18+
}
19+
20+
_nlt_pb_cols() {
21+
if [[ -n "${COLUMNS:-}" ]] && [[ "${COLUMNS}" =~ ^[0-9]+$ ]]; then
22+
echo "${COLUMNS}"
23+
elif command -v tput >/dev/null 2>&1 && [[ -t 1 ]]; then
24+
tput cols 2>/dev/null || echo 80
25+
else
26+
echo 80
27+
fi
28+
}
29+
30+
# 字节 → 人类可读(1024 底,一位小数)
31+
nlt_pb_human_bytes() {
32+
awk -v n="${1:-0}" 'BEGIN{
33+
if (n < 0) n = 0
34+
u[1]="KiB"; u[2]="MiB"; u[3]="GiB"; u[4]="TiB"
35+
if (n < 1024) { printf "%d B", n; exit }
36+
x = n / 1024.0
37+
i = 1
38+
while (x >= 1024 && i < 4) { x /= 1024; i++ }
39+
printf "%.1f %s", x, u[i]
40+
}'
41+
}
42+
43+
_nlt_pb_fmt_hms() {
44+
awk -v s="${1:-0}" 'BEGIN{
45+
if (s < 0) s = 0
46+
h = int(s / 3600)
47+
m = int((s % 3600) / 60)
48+
sec = int(s % 60)
49+
if (h > 0) printf "%d:%02d:%02d", h, m, sec
50+
else printf "%d:%02d", m, sec
51+
}'
52+
}
53+
54+
_nlt_pb_parse_content_length() {
55+
local url="$1"
56+
curl -sI -L "$url" 2>/dev/null | awk 'BEGIN{IGNORECASE=1} /^content-length:/ {v=$2+0} END{print v+0}'
57+
}
58+
59+
# nlt_pb_render current_bytes total_bytes label start_epoch
60+
# total_bytes=0 表示总长未知:条形为不确定样式,百分比与 ETA 为 —
61+
nlt_pb_render() {
62+
local cur="${1:-0}" total="${2:-0}" label="${3:-}" start="${4:-0}"
63+
local now cols barw filled pct elapsed rate eta rem slot span
64+
local cur_h total_h rate_h line bar i ch color denom
65+
66+
[[ "${cur}" =~ ^[0-9]+$ ]] || cur=0
67+
[[ "${total}" =~ ^[0-9]+$ ]] || total=0
68+
[[ "${start}" =~ ^[0-9]+$ ]] || start="$(_nlt_pb_now_s)"
69+
70+
now="$(_nlt_pb_now_s)"
71+
elapsed=$((now - start))
72+
[[ "$elapsed" -lt 1 ]] && elapsed=1
73+
74+
cur_h="$(nlt_pb_human_bytes "$cur")"
75+
76+
if [[ ! -t 1 ]]; then
77+
return 0
78+
fi
79+
80+
cols="$(_nlt_pb_cols)"
81+
barw=$((cols - 52))
82+
[[ "$barw" -lt 12 ]] && barw=12
83+
[[ "$barw" -gt 40 ]] && barw=40
84+
denom=$((barw - 1))
85+
[[ "$denom" -lt 1 ]] && denom=1
86+
87+
rate=$(awk -v c="$cur" -v e="$elapsed" 'BEGIN { if (e > 0) printf "%.0f", c / e; else print 0 }')
88+
rate_h="$(nlt_pb_human_bytes "$rate")/s"
89+
90+
if [[ "$total" -gt 0 ]]; then
91+
pct=$(awk -v c="$cur" -v t="$total" 'BEGIN {
92+
if (t <= 0) { print 0; exit }
93+
p = 100.0 * c / t
94+
if (p > 100) p = 100
95+
printf "%.1f", p
96+
}')
97+
total_h="$(nlt_pb_human_bytes "$total")"
98+
rem=$((total - cur))
99+
[[ "$rem" -lt 0 ]] && rem=0
100+
if [[ "$rate" -gt 0 ]]; then
101+
eta=$(awk -v r="$rem" -v rt="$rate" 'BEGIN { if (rt > 0) printf "%.0f", r / rt; else print 0 }')
102+
eta="$(_nlt_pb_fmt_hms "$eta")"
103+
else
104+
eta=""
105+
fi
106+
filled=$(awk -v c="$cur" -v t="$total" -v w="$barw" 'BEGIN {
107+
if (t <= 0) { print 0; exit }
108+
f = int(w * c / t + 0.5)
109+
if (f > w) f = w
110+
if (f < 0) f = 0
111+
print f
112+
}')
113+
else
114+
pct=""
115+
total_h="?"
116+
eta=""
117+
# 不确定:滑动的 4 格亮块
118+
span=4
119+
slot=$(( (elapsed * 2) % (barw - span + 1) ))
120+
filled=-1
121+
fi
122+
123+
bar=""
124+
if [[ "$filled" -ge 0 ]]; then
125+
for ((i = 0; i < barw; i++)); do
126+
if [[ "$i" -lt "$filled" ]]; then
127+
color=$((39 + (i * 6 / denom)))
128+
[[ "$color" -gt 45 ]] && color=45
129+
ch=$''
130+
bar+="$(printf '\033[38;5;%dm%s' "$color" "$ch")"
131+
else
132+
bar+=$'\033[38;5;238m░'
133+
fi
134+
done
135+
else
136+
for ((i = 0; i < barw; i++)); do
137+
if [[ "$i" -ge "$slot" && "$i" -lt $((slot + span)) ]]; then
138+
color=$((39 + (i * 6 / denom)))
139+
[[ "$color" -gt 45 ]] && color=45
140+
ch=$''
141+
bar+="$(printf '\033[38;5;%dm%s' "$color" "$ch")"
142+
else
143+
bar+=$'\033[38;5;238m░'
144+
fi
145+
done
146+
fi
147+
148+
line="$(printf '\r\033[1;36m%s\033[0m [\033[0m%s\033[0m] \033[33m%s%%\033[0m \033[35m%s\033[0m/\033[35m%s\033[0m \033[32m%s\033[0m \033[2melapsed %s\033[0m \033[2mETA %s\033[0m' \
149+
"$label" "${bar}" "$pct" "$cur_h" "$total_h" "$rate_h" "$(_nlt_pb_fmt_hms "$elapsed")" "$eta")"
150+
printf '%s\033[K' "$line"
151+
}
152+
153+
nlt_pb_done() {
154+
[[ -t 1 ]] && printf '\n' || true
155+
}
156+
157+
# nlt_pb_curl_to_file url dest [optional_total_bytes]
158+
# 可选第三参为已知总字节;否则尝试 HEAD 解析 Content-Length。
159+
nlt_pb_curl_to_file() {
160+
local url="$1" dest="$2" total="${3:-}"
161+
local start curl_pid ec size last_plain now
162+
163+
[[ -n "$url" && -n "$dest" ]] || return 2
164+
command -v curl >/dev/null 2>&1 || return 127
165+
166+
if [[ -z "$total" || ! "$total" =~ ^[0-9]+$ ]]; then
167+
total="$(_nlt_pb_parse_content_length "$url")"
168+
fi
169+
[[ ! "$total" =~ ^[0-9]+$ ]] && total=0
170+
171+
start="$(_nlt_pb_now_s)"
172+
ec=0
173+
last_plain=0
174+
175+
rm -f "$dest"
176+
curl -fL --connect-timeout 30 -o "$dest" "$url" &
177+
curl_pid=$!
178+
179+
if [[ -t 1 ]]; then
180+
while kill -0 "$curl_pid" 2>/dev/null; do
181+
size="$(_nlt_file_size "$dest")"
182+
nlt_pb_render "$size" "$total" "${NLT_PB_LABEL:-download}" "$start"
183+
sleep 0.25
184+
done
185+
else
186+
while kill -0 "$curl_pid" 2>/dev/null; do
187+
size="$(_nlt_file_size "$dest")"
188+
now="$(_nlt_pb_now_s)"
189+
if [[ $((now - last_plain)) -ge 5 ]]; then
190+
if [[ "${NONINTERACTIVE:-}" != "1" ]]; then
191+
if [[ "$total" -gt 0 ]]; then
192+
printf '%s: %s / %s bytes\n' "${NLT_PB_LABEL:-download}" "$size" "$total" >&2
193+
else
194+
printf '%s: %s bytes\n' "${NLT_PB_LABEL:-download}" "$size" >&2
195+
fi
196+
fi
197+
last_plain=$now
198+
fi
199+
sleep 0.25
200+
done
201+
fi
202+
203+
wait "$curl_pid" || ec=$?
204+
205+
size="$(_nlt_file_size "$dest")"
206+
if [[ "$total" -eq 0 && "$size" -gt 0 ]]; then
207+
total=$size
208+
fi
209+
if [[ -t 1 ]]; then
210+
nlt_pb_render "$size" "$total" "${NLT_PB_LABEL:-download}" "$start"
211+
nlt_pb_done
212+
fi
213+
214+
return "$ec"
215+
}

tests/progress_smoke.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail
3+
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
4+
bash -n "${ROOT}/scripts/lib/nlt-progress.sh"
5+
# shellcheck source=../scripts/lib/nlt-progress.sh
6+
source "${ROOT}/scripts/lib/nlt-progress.sh"
7+
export NONINTERACTIVE=1
8+
# 非 TTY:确保不崩溃
9+
nlt_pb_render 50 100 "smoke" "$(date +%s)" 2>/dev/null || true
10+
echo "progress_smoke ok"

0 commit comments

Comments
 (0)