diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2383dbb..20d3303 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,17 @@ jobs: echo "${out}" echo "${out}" | grep -q '^OK: all' + - name: Unit test (tui window/row math) + run: | + set -euo pipefail + # Pure window/row-count helpers (the breakage-prone math). The + # interactive prompt is not unit-tested; these are. No autotools. + "${CC}" -D_DEFAULT_SOURCE -Wall -Wextra -g \ + src/tui.c src/test_tui.c -o /tmp/test_tui + out="$(/tmp/test_tui)" + echo "${out}" + echo "${out}" | grep -q '^OK: all' + - name: Unit test (hls) run: | set -euo pipefail @@ -403,27 +414,27 @@ jobs: } trap cleanup EXIT - # A series page listing three episode pages; each episode page embeds - # a pointing at a distinct media file. The config 'list's the - # episode hrefs; the per-episode pipeline resolves each . - head -c 4096 /dev/urandom > "${srvdir}/ep1.bin" - head -c 8192 /dev/urandom > "${srvdir}/ep2.bin" - head -c 16384 /dev/urandom > "${srvdir}/ep3.bin" + # A series page listing a LARGE number of episode pages (N=60) to + # prove there is NO cap and the last episode stays reachable. Each + # episode page embeds a pointing at a distinct media file. + # The config 'list's the episode hrefs; the per-episode pipeline + # resolves each . + N=60 mkdir -p "${srvdir}/play" - for i in 1 2 3; do - cat > "${srvdir}/play/Ep0${i}.html" <' + for i in $(seq 1 "${N}"); do + ii="$(printf '%02d' "${i}")" + head -c $((1024 + i)) /dev/urandom > "${srvdir}/ep${i}.bin" + cat > "${srvdir}/play/Ep${ii}.html" < HTML - done - cat > "${srvdir}/series.html" <<'HTML' - - 1 - 2 - 3 - - HTML + echo "${i}" + done + echo '' + } > "${srvdir}/series.html" port="$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()')" python3 -m http.server "${port}" \ @@ -453,32 +464,53 @@ jobs: cmp -s "$1" "$2" || { echo "byte mismatch: $2" >&2; exit 1; } } - # --episodes 1,3 : exactly episodes 1 and 3, byte-exact; 2 absent. + # --episodes over a LARGE list: a spread including the LAST episode + # (60) proves no truncation. Exactly {1,3,60} land; 2 is absent. out="${workdir}/sel" mkdir -p "${out}" - "${flux}" -q --episodes 1,3 -o "${out}/" \ + "${flux}" -q --episodes 1,3,60 -o "${out}/" \ "http://127.0.0.1:${port}/series.html" - assert_match "${srvdir}/ep1.bin" "${out}/ep01-ep1.bin" - assert_match "${srvdir}/ep3.bin" "${out}/ep03-ep3.bin" - test ! -e "${out}/ep02-ep2.bin" \ + assert_match "${srvdir}/ep1.bin" "${out}/ep1.bin" + assert_match "${srvdir}/ep3.bin" "${out}/ep3.bin" + assert_match "${srvdir}/ep60.bin" "${out}/ep60.bin" + test ! -e "${out}/ep2.bin" \ || { echo "episode 2 should not have been downloaded" >&2; exit 1; } - # --all : every episode, byte-exact. + # Re-run with ep1 already present: it must be SKIPPED, the newly + # requested ep2 downloaded. Proves on-disk status + skip-if-present. + "${flux}" --episodes 1,2 -o "${out}/" \ + "http://127.0.0.1:${port}/series.html" > "${workdir}/rerun.log" 2>&1 + grep -q "already on disk, skipping" "${workdir}/rerun.log" \ + || { echo "re-run did not skip the on-disk episode" >&2; + cat "${workdir}/rerun.log" >&2; exit 1; } + assert_match "${srvdir}/ep2.bin" "${out}/ep2.bin" + + # --all : every one of the N episodes, byte-exact; count matches N. alldir="${workdir}/all" mkdir -p "${alldir}" "${flux}" -q --all -o "${alldir}/" \ "http://127.0.0.1:${port}/series.html" - assert_match "${srvdir}/ep1.bin" "${alldir}/ep01-ep1.bin" - assert_match "${srvdir}/ep2.bin" "${alldir}/ep02-ep2.bin" - assert_match "${srvdir}/ep3.bin" "${alldir}/ep03-ep3.bin" - - # A bad --episodes spec must be rejected with a non-zero exit. - if "${flux}" -q --episodes 9 -o "${out}/" \ + assert_match "${srvdir}/ep1.bin" "${alldir}/ep1.bin" + assert_match "${srvdir}/ep30.bin" "${alldir}/ep30.bin" + assert_match "${srvdir}/ep60.bin" "${alldir}/ep60.bin" + got="$(ls "${alldir}" | wc -l)" + test "${got}" -eq "${N}" \ + || { echo "expected ${N} files, got ${got}" >&2; exit 1; } + + # --all RE-RUN: everything is present, so 0 downloaded, N skipped. + "${flux}" --all -o "${alldir}/" \ + "http://127.0.0.1:${port}/series.html" > "${workdir}/all2.log" 2>&1 + grep -qE "0 downloaded, ${N} skipped" "${workdir}/all2.log" \ + || { echo "re-run --all should skip everything" >&2; + tail -3 "${workdir}/all2.log" >&2; exit 1; } + + # A bad --episodes spec (out of range over N) must exit non-zero. + if "${flux}" -q --episodes 99 -o "${out}/" \ "http://127.0.0.1:${port}/series.html" 2>/dev/null; then echo "bad --episodes spec was accepted" >&2; exit 1 fi - echo "Series e2e OK: --episodes 1,3 + --all byte-exact, bad spec rejected" + echo "Series e2e OK: N=${N} no cap, --episodes/--all byte-exact, on-disk skip + re-run, bad spec rejected" sanitizers: name: sanitizers @@ -513,6 +545,19 @@ jobs: echo "${out}" echo "${out}" | grep -q '^OK: all' + - name: ASan/UBSan unit test (tui window/row math) + run: | + set -euo pipefail + # Pure window/row-count helpers under sanitizers. + clang -D_DEFAULT_SOURCE -Wall -Wextra -g \ + -fsanitize=address,undefined -fno-sanitize-recover=all \ + src/tui.c src/test_tui.c -o /tmp/test_tui_san + export ASAN_OPTIONS=detect_leaks=1:abort_on_error=1 + export UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 + out="$(/tmp/test_tui_san)" + echo "${out}" + echo "${out}" | grep -q '^OK: all' + - name: ASan/UBSan unit test (hls) run: | set -euo pipefail diff --git a/ChangeLog b/ChangeLog index 2861cbd..fde1ab3 100644 --- a/ChangeLog +++ b/ChangeLog @@ -4,6 +4,23 @@ kept unchanged for the record, including their original author names. +Version: 2.0.0, 2026-06-18 + + [ Hyperflux contributors ] + +* series: a 'list' config opens an interactive multi-select episode TUI with on-disk + status (missing pre-selected, 'a' selects all); batch download skips files already + present; episodes keep their remote filename; --all and --episodes select + non-interactively +* tui: skills-styled multi-select (no ncurses) with windowed scrolling and no list + cap, wrapping-aware redraw, SIGWINCH-safe, terminal restored on every exit +* extractor: interpolate {var} in var/list regex arguments (slug-anchored episode + lists); animeworld.conf is now a series config; epid charset accepts '-' and '_' +* packaging: bundled extractor configs install active into + /usr/share/hyperflux/extractors (deb/rpm/apk and the native rpm spec) +* scan: --extract-scan saves the generated config into the active user dir by + default; HTML no-match hint + Version: 1.1.0, 2026-06-18 [ Hyperflux contributors ] diff --git a/NEWS b/NEWS index c79e734..3419e89 100644 --- a/NEWS +++ b/NEWS @@ -1,3 +1,30 @@ +Hyperflux 2.0.0 (2026-06-18) +============================ + +Download a whole series, not just one file. Point flux at any episode page of a +supported site and it opens an interactive picker listing every episode: the ones +already on disk are ticked, the rest start selected, and one key selects them all. +Confirm and flux downloads only what's missing, keeping each file under the same +name it has on the site, so re-running later grabs just the new episodes. + +* The episode picker is a clean terminal UI: arrow keys or j/k to move, space to + toggle, "a" to select all, enter to download, q to quit. It scrolls through long + series with no cap and redraws cleanly when the terminal is resized. + +* Extractor configs now work out of the box: the bundled config installs active in a + system directory, so a fresh install resolves matching pages with no setup, and + "flux --extract-scan " writes the config it generates straight into your + active config dir. A download that turns out to be a web page now hints you to run + --extract-scan instead of silently saving the HTML. + +* Config regexes can interpolate captured variables, which is what lets a config + enumerate a series' episodes. + +Everything from 1.x still applies: a page that matches no config is a normal direct +download, HLS streams are fetched in parallel and assembled, and packages are on the +apt/dnf/apk Cloudsmith repositories. + + Hyperflux 1.1.0 (2026-06-18) ============================ diff --git a/VERSION b/VERSION index f7d4ff9..28b775b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.0 2026-06-18T12:57:14Z +2.0.0 2026-06-18T17:16:13Z diff --git a/examples/extractors/animeworld.conf b/examples/extractors/animeworld.conf index b44af72..4f284a8 100644 --- a/examples/extractors/animeworld.conf +++ b/examples/extractors/animeworld.conf @@ -9,6 +9,9 @@ # # Then just run: flux 'https://www.animeworld.ac/play/anime-name.xxxx/AbC123' # +# This is a SERIES config: the play page lists every episode of the series, so +# `flux ` opens an episode multi-select and downloads what you pick. +# # SECURITY NOTE: an extractor config issues arbitrary HTTP requests with the # headers it declares. Treat shared configs like scripts and review them before # use. Hyperflux never fetches configs from a URL and never forwards your @@ -21,9 +24,24 @@ name animeworld # Multiple `match` lines are OR-ed. POSIX Extended Regular Expression. match animeworld\.[a-z]+/play/ -# The episode id is the last path segment of the page URL. -# `var <- regex ` captures group 1 of the ERE. -var epid <- url regex /play/[^/]+/([A-Za-z0-9]+) +# --- series setup (runs once on the page; skipped per-episode) --------------- +# +# The play page of any episode lists ALL episodes of that series. Capture the +# series slug ("anime-name.animeid") from the page URL, fetch the page, then +# `list` only the /play// hrefs so just THIS series is captured. +# A {var} in a var/list regex is interpolated against the store before the +# regex compiles, so {slug} anchors the list to this series. +var slug <- url regex /play/([^/]+)/ +get page <- {url} +list eps <- page regex href="(/play/{slug}/[A-Za-z0-9_-]+)" + +# --- per-episode pipeline (runs once per selected episode, with {url} bound to +# that episode's page URL; the directives above are skipped here) ------------- + +# The episode id is the last path segment of the episode URL. epids may contain +# '-' and '_' (e.g. "VbGT-F"), so the charset is [A-Za-z0-9_-]+, not just +# alphanumerics. +var epid <- url regex /play/[^/]+/([A-Za-z0-9_-]+) # The landing HTML does NOT contain the media URL; the player loads it from an # internal API. `get` performs an HTTP GET and names the response body so later diff --git a/po/Makevars b/po/Makevars index 2573b31..e91263f 100644 --- a/po/Makevars +++ b/po/Makevars @@ -11,7 +11,7 @@ subdir = po top_builddir = .. # These options get passed to xgettext. -XGETTEXT_OPTIONS = --keyword=_ --keyword=N_ --omit-header +XGETTEXT_OPTIONS = --from-code=UTF-8 --keyword=_ --keyword=N_ --omit-header # This is the copyright holder that gets inserted into the header of the # $(DOMAIN).pot file. Set this to the copyright holder of the surrounding diff --git a/src/extractor.c b/src/extractor.c index 7ea2b7f..eac9c8d 100644 --- a/src/extractor.c +++ b/src/extractor.c @@ -886,16 +886,24 @@ extractor_run(const extractor_t *ex, const char *page_url, d->name, d->source); goto done; } + /* Interpolate {var}s in the regex before it compiles. */ + char *ere = interpolate(d->arg, &st, err); + if (!ere) + goto done; char *cap = NULL; - int r = regex_capture1(d->arg, src, &cap, err); - if (r < 0) + int r = regex_capture1(ere, src, &cap, err); + if (r < 0) { + free(ere); goto done; + } if (r == 0) { ext_set_err(err, ex->path, 0, "var '%s': regex /%s/ did not match source '%s'", - d->name, d->arg, d->source); + d->name, ere, d->source); + free(ere); goto done; } + free(ere); if (store_set(&st, d->name, cap) < 0) { ext_set_err(err, ex->path, 0, "var '%s': store full", d->name); @@ -1191,8 +1199,13 @@ extractor_list_episodes(const extractor_t *ex, const char *page_url, d->name, d->source); goto done; } + /* Interpolate {var}s (e.g. a slug) before the regex + * compiles, so the list can be anchored to this series. */ + char *ere = interpolate(d->arg, &st, err); + if (!ere) + goto done; char *cerr = NULL; - int rc = regex_capture_all(d->arg, src, + int rc = regex_capture_all(ere, src, EXTRACTOR_MAX_EPISODES, &raw, &nraw, &cerr); if (rc < 0) { @@ -1200,14 +1213,17 @@ extractor_list_episodes(const extractor_t *ex, const char *page_url, *err = cerr; else free(cerr); + free(ere); goto done; } if (nraw == 0) { ext_set_err(err, ex->path, 0, "list '%s': regex /%s/ matched no items in source '%s'", - d->name, d->arg, d->source); + d->name, ere, d->source); + free(ere); goto done; } + free(ere); break; /* one 'list' per config; stop at the first */ } else if (d->kind == EXT_DIR_VAR) { const char *src = store_get(&st, d->source, @@ -1218,16 +1234,24 @@ extractor_list_episodes(const extractor_t *ex, const char *page_url, d->name, d->source); goto done; } + /* Interpolate {var}s in the regex before it compiles. */ + char *ere = interpolate(d->arg, &st, err); + if (!ere) + goto done; char *cap = NULL; - int r = regex_capture1(d->arg, src, &cap, err); - if (r < 0) + int r = regex_capture1(ere, src, &cap, err); + if (r < 0) { + free(ere); goto done; + } if (r == 0) { ext_set_err(err, ex->path, 0, "var '%s': regex /%s/ did not match source '%s'", - d->name, d->arg, d->source); + d->name, ere, d->source); + free(ere); goto done; } + free(ere); if (store_set(&st, d->name, cap) < 0) { ext_set_err(err, ex->path, 0, "var '%s': store full", d->name); diff --git a/src/test_extractor.c b/src/test_extractor.c index 6b2fd57..098b95e 100644 --- a/src/test_extractor.c +++ b/src/test_extractor.c @@ -319,6 +319,91 @@ test_list_episodes(void) extractor_free(ex); } +/* {var} in a var/list regex is interpolated against the store before regcomp, + * so a slug captured from the URL can anchor the list to one series. The fixture + * mixes this series' /play/ links with another series' links the slug excludes, + * and uses real data-num attributes plus an epid containing '-' and '_'. */ +static const char *SERIES_HTML_SLUG = + "\n" + "1\n" + "2\n" + "3\n" + "other 1\n" + "\n"; + +/* The slug-anchored list: capture {slug} from {url}, fetch the page, then list + * only this series' /play/{slug}/ hrefs. epids may carry '-'/'_'. */ +static void +test_list_episodes_slug_interp(void) +{ + const char *cfg = + "name series\n" + "match example\\.[a-z]+/play/\n" + "var slug <- url regex /play/([^/]+)/\n" + "get page <- {url}\n" + "list eps <- page regex href=\"(/play/{slug}/[A-Za-z0-9_-]+)\"\n" + "var epid <- url regex /play/[^/]+/([A-Za-z0-9_-]+)\n" + "output https://cdn.example.com/v/{epid}.mp4\n"; + char *err = NULL; + extractor_t *ex = extractor_parse(cfg, "series.conf", &err); + CHECK(ex != NULL, "slug-interp series config parses"); + if (!ex) { + printf(" parse err: %s\n", err ? err : "(none)"); + free(err); + return; + } + + char **urls = NULL; + size_t n = 0; + int r = extractor_list_episodes(ex, + "https://example.tv/play/anime-name.4242/Ep01", + series_fetch, (void *)SERIES_HTML_SLUG, &urls, &n, &err); + CHECK(r == 0, "slug-anchored list_episodes succeeds"); + if (r != 0) { + printf(" list err: %s\n", err ? err : "(none)"); + free(err); + extractor_free(ex); + return; + } + CHECK(n == 3, "slug list captured exactly this series' 3 episodes"); + if (n == 3) { + CHECK_STR(urls[0], + "https://example.tv/play/anime-name.4242/Ep01", + "slug ep1 absolute"); + CHECK_STR(urls[1], + "https://example.tv/play/anime-name.4242/VbGT-F", + "slug ep2 keeps a '-' in the epid"); + CHECK_STR(urls[2], + "https://example.tv/play/anime-name.4242/a_b-C9", + "slug ep3 keeps '_' and '-' in the epid"); + } + extractor_free_urls(urls, n); + extractor_free(ex); +} + +/* An interpolated {var} in a regex that is undefined must fail clearly. */ +static void +test_regex_interp_undefined_var(void) +{ + const char *cfg = + "name x\n" + "var a <- url regex /play/({missing})\n" + "output {a}\n"; + char *err = NULL; + extractor_t *ex = extractor_parse(cfg, "x.conf", &err); + CHECK(ex != NULL, "regex-undefined-var config parses"); + if (!ex) { free(err); return; } + + char *media = NULL; + int r = extractor_run(ex, "https://h/play/foo", NULL, NULL, &media, &err); + CHECK(r < 0, "undefined {var} in a regex fails the run"); + CHECK(media == NULL, "no media URL on undefined regex var"); + CHECK(err && strstr(err, "missing"), + "error names the undefined regex variable"); + free(err); + extractor_free(ex); +} + /* A per-episode run must SKIP the 'list' directive and resolve {url} to media. */ static void test_per_episode_run_skips_list(void) @@ -510,6 +595,8 @@ main(void) test_run_nomatch_var(); test_run_undefined_var(); test_list_episodes(); + test_list_episodes_slug_interp(); + test_regex_interp_undefined_var(); test_per_episode_run_skips_list(); test_pre_list_setup_not_rerun(); test_episodes_spec(); diff --git a/src/test_tui.c b/src/test_tui.c new file mode 100644 index 0000000..00c1a25 --- /dev/null +++ b/src/test_tui.c @@ -0,0 +1,170 @@ +/* Standalone unit tests for the TUI's pure window/row-count math. The + * interactive prompt is not unit-tested, but the windowing and physical-row + * counting ARE, since miscounting wrapped rows is the documented breakage mode + * (the prompt reprints itself when clearRender under-counts). Build: + * cc -D_DEFAULT_SOURCE -Wall -Wextra -g \ + * src/tui.c src/test_tui.c -o /tmp/test_tui && /tmp/test_tui + * + * SPDX-License-Identifier: GPL-2.0-or-later + */ + +#include +#include + +#include "tui.h" + +static int failures; +static int checks; + +#define CHECK(cond, msg) \ + do { \ + checks++; \ + if (!(cond)) { \ + printf("FAIL %s\n", (msg)); \ + failures++; \ + } \ + } while (0) + +#define CHECK_EQ(got, want, msg) \ + do { \ + checks++; \ + if ((size_t)(got) != (size_t)(want)) { \ + printf("FAIL %s: got %zu want %zu\n", (msg), \ + (size_t)(got), (size_t)(want)); \ + failures++; \ + } \ + } while (0) + +/* ---- tui_visible_start: windowed scrolling over arbitrary N -------------- */ + +static void +test_visible_start_small(void) +{ + /* Whole list fits in the window: always start at 0. */ + CHECK_EQ(tui_visible_start(0, 5, 8), 0, "fits: cursor 0"); + CHECK_EQ(tui_visible_start(4, 5, 8), 0, "fits: cursor last"); + CHECK_EQ(tui_visible_start(0, 8, 8), 0, "exact fit: start 0"); +} + +static void +test_visible_start_scroll(void) +{ + /* 20 items, window 8, half = 4. start = clamp(cursor-4, 0, 12). */ + CHECK_EQ(tui_visible_start(0, 20, 8), 0, "top: clamps to 0"); + CHECK_EQ(tui_visible_start(3, 20, 8), 0, "near top: still 0 (3-4<0)"); + CHECK_EQ(tui_visible_start(4, 20, 8), 0, "cursor 4: 4-4=0"); + CHECK_EQ(tui_visible_start(10, 20, 8), 6, "mid: 10-4=6"); + CHECK_EQ(tui_visible_start(16, 20, 8), 12, "near end: clamps to 12"); + CHECK_EQ(tui_visible_start(19, 20, 8), 12, "last: clamps to len-window"); +} + +static void +test_visible_start_large(void) +{ + /* No cap on N: a 200-item list windows correctly throughout, and every + * item stays reachable (the window's last index covers len-1). */ + size_t len = 200, win = 12, half = win / 2; + for (size_t cursor = 0; cursor < len; cursor++) { + size_t start = tui_visible_start(cursor, len, win); + /* The cursor is always inside [start, start+win). */ + CHECK(cursor >= start && cursor < start + win, + "large N: cursor stays within the window"); + /* start never exceeds len-win and is never negative (unsigned). */ + CHECK(start <= len - win, "large N: start clamped to len-window"); + /* Matches the documented formula. */ + size_t want = cursor > half ? cursor - half : 0; + if (want > len - win) + want = len - win; + CHECK_EQ(start, want, "large N: start matches the formula"); + } + /* The last window reaches the final item (nothing is unreachable). */ + size_t laststart = tui_visible_start(len - 1, len, win); + CHECK_EQ(laststart + win, len, "large N: last window ends at len"); +} + +static void +test_visible_start_edge(void) +{ + CHECK_EQ(tui_visible_start(0, 0, 8), 0, "empty list: start 0"); + CHECK_EQ(tui_visible_start(0, 10, 0), 0, "zero window: start 0"); + CHECK_EQ(tui_visible_start(9, 10, 1), 9, "window 1: start tracks cursor"); +} + +/* ---- tui_visual_rows_for_line: physical rows with wrapping + ANSI -------- */ + +static void +test_rows_plain(void) +{ + CHECK_EQ(tui_visual_rows_for_line("", 80), 1, "empty line is 1 row"); + CHECK_EQ(tui_visual_rows_for_line("hello", 80), 1, "short line is 1 row"); + /* 80 cells in 80 cols = exactly 1 row; 81 cells = 2 rows. */ + char c80[81], c81[82]; + memset(c80, 'x', 80); c80[80] = '\0'; + memset(c81, 'x', 81); c81[81] = '\0'; + CHECK_EQ(tui_visual_rows_for_line(c80, 80), 1, "80 in 80 cols: 1 row"); + CHECK_EQ(tui_visual_rows_for_line(c81, 80), 2, "81 in 80 cols: 2 rows"); +} + +static void +test_rows_wrapping(void) +{ + char c200[201]; + memset(c200, 'x', 200); c200[200] = '\0'; + /* 200 cells / 80 cols = ceil(2.5) = 3 rows. */ + CHECK_EQ(tui_visual_rows_for_line(c200, 80), 3, "200 in 80: 3 rows"); + /* Narrow terminal: 200 / 20 = 10 rows. */ + CHECK_EQ(tui_visual_rows_for_line(c200, 20), 10, "200 in 20: 10 rows"); + /* columns clamped to >= 1: 5 cells in "0" cols -> treat as 1 col -> 5. */ + CHECK_EQ(tui_visual_rows_for_line("abcde", 0), 5, "0 cols clamps to 1"); +} + +static void +test_rows_ansi_not_counted(void) +{ + /* SGR escapes contribute zero display width: a green 5-char word fits + * in 1 row even though the byte string is much longer. */ + const char *colored = "\033[32mhello\033[0m"; + CHECK_EQ(tui_visual_rows_for_line(colored, 80), 1, + "ANSI SGR not counted toward width"); + /* A line of exactly 80 visible cells wrapped in colors is still 1 row. */ + char buf[256]; + char body[81]; + memset(body, 'y', 80); body[80] = '\0'; + snprintf(buf, sizeof(buf), "\033[2m\033[36m%s\033[0m", body); + CHECK_EQ(tui_visual_rows_for_line(buf, 80), 1, + "80 visible cells + ANSI: still 1 row"); + /* 81 visible cells + ANSI wraps to 2. */ + char body2[82]; + memset(body2, 'y', 81); body2[81] = '\0'; + snprintf(buf, sizeof(buf), "\033[1m%s\033[0m", body2); + CHECK_EQ(tui_visual_rows_for_line(buf, 80), 2, + "81 visible cells + ANSI: 2 rows"); +} + +static void +test_rows_utf8(void) +{ + /* Three '│' bars (3 UTF-8 bytes each) count as 3 cells, not 9. */ + const char *bars = "\xE2\x94\x82\xE2\x94\x82\xE2\x94\x82"; + CHECK_EQ(tui_visual_rows_for_line(bars, 80), 1, "UTF-8 bars: 3 cells, 1 row"); + CHECK_EQ(tui_visual_rows_for_line(bars, 2), 2, "3 cells in 2 cols: 2 rows"); +} + +int +main(void) +{ + test_visible_start_small(); + test_visible_start_scroll(); + test_visible_start_large(); + test_visible_start_edge(); + test_rows_plain(); + test_rows_wrapping(); + test_rows_ansi_not_counted(); + test_rows_utf8(); + + if (failures == 0) + printf("OK: all %d checks passed\n", checks); + else + printf("%d/%d checks FAILED\n", failures, checks); + return failures ? 1 : 0; +} diff --git a/src/text.c b/src/text.c index 3df1dc1..97c59fd 100644 --- a/src/text.c +++ b/src/text.c @@ -1019,17 +1019,11 @@ episode_basename(char *dst, size_t dlen, const char *media_url, size_t num) { if (!dlen) return; + dst[0] = '\0'; - /* Always-unique numeric prefix. */ - int pre = snprintf(dst, dlen, "ep%02zu", num); - if (pre <= 0 || (size_t)pre >= dlen) { - if (dlen) - dst[0] = '\0'; - return; - } - size_t o = (size_t)pre; - - /* Take the segment after the last '/', dropping any query/fragment. */ + /* Take the segment after the last '/', dropping any query/fragment, and use + * it verbatim (only illegal chars sanitized) so the local file keeps the same + * name it has on the site. */ const char *p = media_url; const char *sep = strstr(media_url, "://"); if (sep) @@ -1040,49 +1034,79 @@ episode_basename(char *dst, size_t dlen, const char *media_url, size_t num) last = c + 1; size_t seglen = strcspn(last, "?#"); - /* Sanitize and append "-" only when the leaf has a useful char. */ + size_t o = 0; int useful = 0; - for (size_t i = 0; i < seglen; i++) { + for (size_t i = 0; i < seglen && o + 1 < dlen; i++) { unsigned char c = (unsigned char)last[i]; if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-') { + dst[o++] = (char)c; if (c != '.') useful = 1; + } else { + dst[o++] = '_'; } } + dst[o] = '\0'; + + /* No usable leaf (URL ends in '/'): fall back to a numbered name. */ if (!useful) - return; /* nothing meaningful to add: keep "epNN" */ + snprintf(dst, dlen, "episode-%02zu", num); +} - if (o + 1 < dlen) - dst[o++] = '-'; - for (size_t i = 0; i < seglen && o + 1 < dlen; i++) { - unsigned char c = (unsigned char)last[i]; - if ((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || - (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-') - dst[o++] = (char)c; - else - dst[o++] = '_'; +/* Build the on-disk output PATH for episode #num given its (already resolved) + * media URL and the -o destination. For a plain -o file the path is out_name + * verbatim; for a directory (or empty -o) it is "/" / "" + * where is episode_basename(media_url, num). Returns 0 on success, + * -1 if the path would overflow dst. */ +static int +episode_outpath(char *dst, size_t dlen, const char *out_name, int out_is_file, + int out_is_dir, const char *media_url, size_t num) +{ + if (out_is_file) { + if (strlen(out_name) >= dlen) + return -1; + strlcpy(dst, out_name, dlen); + return 0; } - dst[o] = '\0'; + char base[96]; + episode_basename(base, sizeof(base), media_url, num); + if (out_is_dir) { + int w = snprintf(dst, dlen, "%s/%s", out_name, base); + if (w <= 0 || (size_t)w >= dlen) + return -1; + } else { + if (strlen(base) >= dlen) + return -1; + strlcpy(dst, base, dlen); + } + return 0; } /* Batch-download a selected subset of a series' episodes. * - * episode_urls[0..nepisodes) are the ordered episode page URLs. Selection comes - * from --all, --episodes , or (interactively) the multi-select TUI. Each - * selected episode is re-resolved through the per-episode pipeline and - * downloaded; one failure does not abort the rest. out_name, if a directory (or - * empty), receives per-episode files; a plain -o file name is only honoured for - * a single selected episode (else it would clobber). Returns 0 if all selected - * succeeded, 2 on interrupt, 1 if any failed or on a setup error. */ + * episode_urls[0..nepisodes) are the ordered episode page URLs. Each episode is + * resolved up-front (one request apiece) to its media URL so we can derive the + * remote filename and check whether it already exists in the target directory. + * Selection comes from --all, --episodes , or (interactively) the + * skills-styled multi-select TUI (on-disk episodes start unselected; missing + * ones start selected). Selected episodes are downloaded, SKIPPING any whose + * file already exists; one failure does not abort the rest. out_name, if a + * directory (or empty), receives per-episode files; a plain -o file name is only + * honoured for a single selected episode (else it would clobber). Returns 0 if + * all attempted succeeded, 2 on interrupt, 1 if any failed or on a setup error. */ static int run_series(conf_t *conf, const char *page_url, const char *extract_name, char **episode_urls, size_t nepisodes, int select_all, const char *episodes_spec, int auto_yes, const char *out_name, const char *hls_quality, const char *hls_mux) { - unsigned char *sel = NULL; /* sel[i] != 0 -> download episode i */ + unsigned char *sel = NULL; /* sel[i] != 0 -> requested */ size_t nsel = 0; + size_t ndisk = 0; + size_t to_download = 0; + size_t done = 0, failed = 0, skipped = 0, attempted = 0; + int ret = 1; if (select_all && episodes_spec) { fprintf(stderr, @@ -1090,11 +1114,53 @@ run_series(conf_t *conf, const char *page_url, const char *extract_name, return 1; } + int out_is_dir = out_name && *out_name && is_directory(out_name); + int out_is_file = out_name && *out_name && !out_is_dir; + + /* Resolve every episode up-front so we can show on-disk status and avoid + * re-resolving at download time. This is N requests; that is accepted. */ + char **media_urls = calloc(nepisodes, sizeof(*media_urls)); + unsigned char *on_disk = calloc(nepisodes, 1); + char (*outpaths)[MAX_STRING] = calloc(nepisodes, sizeof(*outpaths)); + if (!media_urls || !on_disk || !outpaths) { + fprintf(stderr, _("flux: out of memory\n")); + goto cleanup; + } + + if (conf->verbose >= 0) + printf(_("Resolving %zu episodes\xE2\x80\xA6\n"), nepisodes); + + for (size_t i = 0; i < nepisodes && run; i++) { + char *media = NULL; + int er = extractor_run_series_episode(page_url, episode_urls[i], + extract_name, + extract_http_fetch, conf, + &media); + if (er < 0 || !media) { + free(media); + media_urls[i] = NULL; /* unresolved: fails at download */ + continue; + } + media_urls[i] = media; + if (episode_outpath(outpaths[i], sizeof(outpaths[i]), out_name, + out_is_file, out_is_dir, media, i + 1) != 0) + continue; /* too long: leave empty, fails later */ + if (outpaths[i][0] && access(outpaths[i], F_OK) == 0) { + on_disk[i] = 1; + ndisk++; + } + } + if (!run) { /* interrupted during resolution */ + ret = 2; + goto cleanup; + } + + /* ---- selection -------------------------------------------------- */ if (select_all) { sel = malloc(nepisodes); if (!sel) { fprintf(stderr, _("flux: out of memory\n")); - return 1; + goto cleanup; } memset(sel, 1, nepisodes); nsel = nepisodes; @@ -1103,53 +1169,84 @@ run_series(conf_t *conf, const char *page_url, const char *extract_name, if (extractor_parse_episodes(episodes_spec, nepisodes, &sel, &nsel, eb, sizeof(eb)) != 0) { fprintf(stderr, "flux: %s\n", eb); - return 1; + goto cleanup; } } else { /* Interactive (or non-TTY fallback) multi-select. With --yes and * no explicit selection on a non-TTY we must not guess: the TUI - * fallback reads a spec from stdin; refuse if that is unavailable. */ + * fallback reads a spec from stdin; refuse if unavailable. */ if (auto_yes && !isatty(STDIN_FILENO)) { fprintf(stderr, _("flux: %zu episodes found; pass --all or --episodes to choose non-interactively.\n"), nepisodes); - return 1; + goto cleanup; } - tui_item_t *items = calloc(nepisodes, sizeof(*items)); - char (*labels)[64] = calloc(nepisodes, sizeof(*labels)); + tui_episode_t *items = calloc(nepisodes, sizeof(*items)); + char (*labels)[160] = calloc(nepisodes, sizeof(*labels)); if (!items || !labels) { free(items); free(labels); fprintf(stderr, _("flux: out of memory\n")); - return 1; + goto cleanup; } + /* Label = episode number + remote filename (basename of the + * resolved media URL); missing pre-selected, on-disk unselected. */ for (size_t i = 0; i < nepisodes; i++) { - snprintf(labels[i], sizeof(labels[i]), - "Episode %zu", i + 1); + const char *leaf = outpaths[i][0] + ? (strrchr(outpaths[i], '/') + ? strrchr(outpaths[i], '/') + 1 : outpaths[i]) + : "(unresolved)"; + snprintf(labels[i], sizeof(labels[i]), "%2zu %s", + i + 1, leaf); items[i].label = labels[i]; - items[i].detail = episode_urls[i]; + items[i].on_disk = on_disk[i]; + items[i].selected = on_disk[i] ? 0 : 1; } + /* Header name: a forced config name, else the /play/ slug with its + * trailing "." dropped (e.g. dr-stone-4-part-3-ita). */ + char sname[128]; + { + const char *ps = strstr(page_url, "/play/"); + const char *s = ps ? ps + 6 : page_url; + size_t sl = strcspn(s, "/"); + size_t cut = sl; + for (size_t k = 0; k < sl; k++) + if (s[k] == '.') + cut = k; /* position of the last '.' */ + if (cut > 0) + sl = cut; /* drop the trailing "." */ + if (sl >= sizeof(sname)) + sl = sizeof(sname) - 1; + memcpy(sname, s, sl); + sname[sl] = '\0'; + if (sname[0] == '\0') + strlcpy(sname, "series", sizeof(sname)); + } + char header[256]; + snprintf(header, sizeof(header), + _("%s \xE2\x80\x94 select episodes to download"), + extract_name ? extract_name : sname); size_t *idx = NULL; - int cnt = tui_select_many(_("Select episodes to download:"), - items, nepisodes, NULL, &idx); + int cnt = tui_episode_select(header, items, nepisodes, ndisk, + nepisodes, &idx); free(items); free(labels); if (cnt < 0) { /* cancelled or OOM */ free(idx); fprintf(stderr, _("flux: episode selection cancelled.\n")); - return 1; + goto cleanup; } if (cnt == 0) { free(idx); fprintf(stderr, _("flux: no episodes selected.\n")); - return 1; + goto cleanup; } sel = calloc(nepisodes, 1); if (!sel) { free(idx); fprintf(stderr, _("flux: out of memory\n")); - return 1; + goto cleanup; } for (int i = 0; i < cnt; i++) if ((size_t)idx[i] < nepisodes) @@ -1158,87 +1255,68 @@ run_series(conf_t *conf, const char *page_url, const char *extract_name, nsel = (size_t)cnt; } - /* A plain -o file (not a directory) can hold only one episode. */ - int out_is_dir = out_name && *out_name && is_directory(out_name); - int out_is_file = out_name && *out_name && !out_is_dir; - if (out_is_file && nsel > 1) { + /* A plain -o file (not a directory) can hold only one episode. Count the + * episodes that will actually download (selected and not already present). */ + for (size_t i = 0; i < nepisodes; i++) + if (sel[i] && !on_disk[i]) + to_download++; + if (out_is_file && to_download > 1) { fprintf(stderr, _("Refusing to write %zu episodes to a single file '%s'; use a directory.\n"), - nsel, out_name); - free(sel); - return 1; + to_download, out_name); + goto cleanup; } - size_t done = 0, failed = 0, attempted = 0; + /* ---- download --------------------------------------------------- */ for (size_t i = 0; i < nepisodes && run; i++) { if (!sel[i]) continue; + if (on_disk[i]) { /* already present: skip, never re-download */ + printf(_("\n=== episode %zu/%zu: already on disk, skipping (%s) ===\n"), + i + 1, nepisodes, outpaths[i]); + skipped++; + continue; + } attempted++; printf(_("\n=== episode %zu/%zu: %s ===\n"), i + 1, nepisodes, episode_urls[i]); - /* Re-run the per-episode pipeline (discovered via the SERIES - * page URL; the engine skips 'list' and binds {url} to this - * episode) to get the concrete media URL. */ - char *media = NULL; - int er = extractor_run_series_episode(page_url, episode_urls[i], - extract_name, - extract_http_fetch, conf, - &media); - if (er < 0 || !media) { + if (!media_urls[i] || !outpaths[i][0]) { fprintf(stderr, _("flux: episode %zu: could not resolve media URL.\n"), i + 1); - free(media); failed++; continue; } if (conf->verbose > 0) - printf(_("Extracted media URL: %s\n"), media); - - /* Build a distinct output path. For a directory (or empty -o) - * derive a per-episode leaf so episodes never clobber. For a - * single-episode plain -o, honour the given name verbatim. */ - char outpath[MAX_STRING]; - if (out_is_file) { - strlcpy(outpath, out_name, sizeof(outpath)); - } else { - char base[96]; - episode_basename(base, sizeof(base), media, i + 1); - if (out_is_dir) { - int w = snprintf(outpath, sizeof(outpath), - "%s/%s", out_name, base); - if (w <= 0 || (size_t)w >= sizeof(outpath)) { - fprintf(stderr, - _("flux: episode %zu: output path too long.\n"), - i + 1); - free(media); - failed++; - continue; - } - } else { - strlcpy(outpath, base, sizeof(outpath)); - } - } + printf(_("Extracted media URL: %s\n"), media_urls[i]); - int r = download_media_url(conf, media, outpath, hls_quality, - hls_mux); - free(media); + int r = download_media_url(conf, media_urls[i], outpaths[i], + hls_quality, hls_mux); if (r == 0) done++; else failed++; } - free(sel); + printf(_("\nEpisodes: %zu requested, %zu downloaded, %zu skipped (on disk), %zu failed.\n"), + nsel, done, skipped, failed); - printf(_("\nEpisodes: %zu requested, %zu downloaded, %zu failed.\n"), - nsel, done, failed); + if (!run && attempted < to_download) + ret = 2; /* interrupted before finishing the batch */ + else + ret = failed ? 1 : 0; - if (!run && attempted < nsel) - return 2; /* interrupted before finishing the batch */ - return failed ? 1 : 0; + cleanup: + if (media_urls) + for (size_t i = 0; i < nepisodes; i++) + free(media_urls[i]); + free(media_urls); + free(on_disk); + free(outpaths); + free(sel); + return ret; } /* Return 1 if p exists and is a directory, 0 otherwise. */ diff --git a/src/tui.c b/src/tui.c index 6c47e9f..1c5c98d 100644 --- a/src/tui.c +++ b/src/tui.c @@ -56,6 +56,7 @@ * `volatile sig_atomic_t` for the flag touched from the signal handler. */ static struct termios tui_saved_termios; static volatile sig_atomic_t tui_raw_active; /* 1 while raw mode is on */ +static volatile sig_atomic_t tui_inline; /* 1 = inline render (no alt screen) */ static volatile sig_atomic_t tui_resized; /* set by SIGWINCH */ static int tui_atexit_installed; /* register cleanup only once */ @@ -72,10 +73,13 @@ tui_restore(void) if (!tui_raw_active) return; tui_raw_active = 0; - /* Show cursor, leave alternate screen. Best-effort: ignore short writes - * on a dying terminal. */ - static const char leave[] = "\033[?25h\033[?1049l"; - ssize_t w = write(STDOUT_FILENO, leave, sizeof(leave) - 1); + /* Show cursor; leave the alternate screen only if we entered it. Inline + * mode renders in the normal buffer, so we just re-show the cursor and + * emit a CRLF so the shell prompt starts on a fresh line. Best-effort: + * ignore short writes on a dying terminal. */ + const char *leave = tui_inline ? "\033[?25h\r\n" : "\033[?25h\033[?1049l"; + size_t leavelen = strlen(leave); + ssize_t w = write(STDOUT_FILENO, leave, leavelen); (void)w; tcsetattr(STDIN_FILENO, TCSAFLUSH, &tui_saved_termios); } @@ -110,10 +114,12 @@ tui_on_winch(int sig) tui_resized = 1; } -/* Enter raw mode and the alternate screen. Returns 0 on success, -1 on +/* Enter raw mode. With `inline_mode` zero we also switch to the alternate + * screen (full-screen menus); with `inline_mode` non-zero we render inline in + * the normal buffer (the skills-styled prompt). Returns 0 on success, -1 on * failure (terminal state unchanged). */ static int -tui_enter(void) +tui_enter(int inline_mode) { struct termios raw; @@ -146,7 +152,7 @@ tui_enter(void) memset(&wa, 0, sizeof(wa)); wa.sa_handler = tui_on_winch; sigemptyset(&wa.sa_mask); - wa.sa_flags = SA_RESTART; /* don't let resize interrupt our read() */ + /* No SA_RESTART: let SIGWINCH interrupt read() so the loop redraws. */ sigaction(SIGWINCH, &wa, &tui_old_winch); tui_handlers_installed = 1; @@ -159,10 +165,13 @@ tui_enter(void) return -1; } tui_raw_active = 1; + tui_inline = inline_mode ? 1 : 0; - /* Enter alternate screen, hide cursor. */ - static const char enter[] = "\033[?1049h\033[?25l"; - ssize_t w = write(STDOUT_FILENO, enter, sizeof(enter) - 1); + /* Full-screen: enter alternate screen + hide cursor. Inline: just hide + * the cursor (the prompt draws in the normal buffer). */ + const char *enter = inline_mode ? "\033[?25l" : "\033[?1049h\033[?25l"; + size_t enterlen = strlen(enter); + ssize_t w = write(STDOUT_FILENO, enter, enterlen); (void)w; return 0; } @@ -317,7 +326,7 @@ tui_select_one(const char *title, const tui_item_t *items, size_t n) if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO) || is_dumb) return tui_fallback(title, items, n); - if (tui_enter() != 0) + if (tui_enter(0) != 0) return tui_fallback(title, items, n); int result = -1; @@ -557,7 +566,7 @@ tui_select_many(const char *title, const tui_item_t *items, size_t n, return r; } - if (tui_enter() != 0) { + if (tui_enter(0) != 0) { int cnt = tui_fallback_multi(title, items, n, sel); if (cnt < 0) { free(sel); @@ -621,3 +630,523 @@ tui_select_many(const char *title, const tui_item_t *items, size_t n, free(sel); return r; } + +/* ---- skills-styled episode multi-select ------------------------------- */ + +/* ANSI SGR (picocolors -> escapes): green/dim/cyan/red/bold/underline/reset. */ +#define SGR_GREEN "\033[32m" +#define SGR_DIM "\033[2m" +#define SGR_CYAN "\033[36m" +#define SGR_RED "\033[31m" +#define SGR_BOLD "\033[1m" +#define SGR_ULINE "\033[4m" +#define SGR_RESET "\033[0m" + +/* UTF-8 glyphs matching the skills reference (◆ ◇ ■ ● ○ │ ❯ └ ✓). */ +#define G_ACTIVE "\xE2\x97\x86" /* ◆ */ +#define G_SUBMIT "\xE2\x97\x87" /* ◇ */ +#define G_CANCEL "\xE2\x96\xA0" /* ■ */ +#define G_RADIO_ON "\xE2\x97\x8F" /* ● */ +#define G_RADIO_OFF "\xE2\x97\x8B" /* ○ */ +#define G_BAR "\xE2\x94\x82" /* │ */ +#define G_CURSOR "\xE2\x9D\xAF" /* ❯ */ +#define G_CLOSER "\xE2\x94\x94" /* └ */ +#define G_TICK "\xE2\x9C\x93" /* ✓ */ + +/* Episode-prompt hint line, shared so the height estimate matches the render. */ +#define EPISODE_HINT SGR_DIM G_BAR \ + " \xE2\x86\x91\xE2\x86\x93 move \xC2\xB7 space select \xC2\xB7" \ + " a all \xC2\xB7 enter confirm \xC2\xB7 q cancel" SGR_RESET + +size_t +tui_visual_rows_for_line(const char *line, size_t columns) +{ + if (!line) + return 1; + if (columns < 1) + columns = 1; + + /* Display width = bytes that are not part of an ANSI SGR escape and not + * UTF-8 continuation bytes (0x80..0xBF). One cell per code point keeps + * this simple; our glyphs are all single-width in modern terminals. */ + size_t width = 0; + for (const char *p = line; *p;) { + if (*p == '\033') { /* skip a CSI "...m" sequence */ + p++; + if (*p == '[') { + p++; + while (*p && *p != 'm') + p++; + if (*p == 'm') + p++; + } + continue; + } + unsigned char c = (unsigned char)*p; + if ((c & 0xC0) != 0x80) /* count leading bytes, skip continuations */ + width++; + p++; + } + size_t rows = (width + columns - 1) / columns; /* ceil */ + return rows ? rows : 1; +} + +size_t +tui_visible_start(size_t cursor, size_t len, size_t max_visible) +{ + if (max_visible == 0 || len <= max_visible) + return 0; + size_t half = max_visible / 2; + size_t maxstart = len - max_visible; /* len > max_visible here */ + size_t start = cursor > half ? cursor - half : 0; + if (start > maxstart) + start = maxstart; + return start; +} + +/* A growable list of heap-owned logical lines built per render. */ +typedef struct { + char **v; + size_t n, cap; +} line_buf_t; + +/* Append a copy of [s] to lb. Returns 0 or -1 (OOM; lb left consistent). */ +static int +lb_push(line_buf_t *lb, const char *s) +{ + if (lb->n >= lb->cap) { + size_t ncap = lb->cap ? lb->cap * 2 : 16; + char **nv = realloc(lb->v, ncap * sizeof(*nv)); + if (!nv) + return -1; + lb->v = nv; + lb->cap = ncap; + } + char *dup = malloc(strlen(s) + 1); + if (!dup) + return -1; + strcpy(dup, s); + lb->v[lb->n++] = dup; + return 0; +} + +static void +lb_free(line_buf_t *lb) +{ + for (size_t i = 0; i < lb->n; i++) + free(lb->v[i]); + free(lb->v); + lb->v = NULL; + lb->n = lb->cap = 0; +} + +/* Byte length of the UTF-8 sequence starting at `p` (p points at a non-NUL), + * clamped so a truncated trailing sequence never advances past the NUL. */ +static size_t +utf8_adv(const char *p) +{ + unsigned char c = (unsigned char)*p; + size_t adv = 1; + if (c >= 0xF0) + adv = 4; + else if (c >= 0xE0) + adv = 3; + else if (c >= 0xC0) + adv = 2; + /* Stop early if the sequence is cut short by the NUL terminator. */ + for (size_t k = 1; k < adv; k++) + if (p[k] == '\0') + return k; + return adv; +} + +/* Truncate `src` to at most `maxcells` display cells, appending an ellipsis + * ".." when it had to cut, into dst[dlen]. Plain text only (no ANSI). Counts + * UTF-8 code points as one cell each. Always NUL-terminates. */ +static void +truncate_cells(char *dst, size_t dlen, const char *src, size_t maxcells) +{ + if (!dlen) + return; + if (maxcells < 1) + maxcells = 1; + + /* Count code points. utf8_adv() never steps past the NUL, so malformed + * input (a truncated lead byte) can't read out of bounds. */ + size_t cells = 0; + const char *p = src; + while (*p) { + p += utf8_adv(p); + cells++; + } + + if (cells <= maxcells) { /* fits: copy verbatim */ + size_t L = strlen(src); + if (L >= dlen) + L = dlen - 1; + memcpy(dst, src, L); + dst[L] = '\0'; + return; + } + + /* Need to cut: reserve two cells for "..". */ + size_t keep = maxcells > 2 ? maxcells - 2 : 1; + cells = 0; + p = src; + while (*p && cells < keep) { + p += utf8_adv(p); + cells++; + } + size_t blen = (size_t)(p - src); + size_t o = 0; + for (size_t i = 0; i < blen && o + 1 < dlen; i++) + dst[o++] = src[i]; + if (o + 2 < dlen) { + dst[o++] = '.'; + dst[o++] = '.'; + } + dst[o] = '\0'; +} + +/* Build the "Selected:" summary (up to 3 labels then "+N more") into dst. */ +static void +build_summary(char *dst, size_t dlen, const tui_episode_t *items, size_t n, + const unsigned char *sel) +{ + if (!dlen) + return; + dst[0] = '\0'; + + size_t total = 0; + for (size_t i = 0; i < n; i++) + if (sel[i]) + total++; + + if (total == 0) { + snprintf(dst, dlen, "(none)"); + return; + } + + size_t o = 0, shown = 0; + for (size_t i = 0; i < n && shown < 3; i++) { + if (!sel[i]) + continue; + const char *lab = items[i].label ? items[i].label : ""; + if (shown > 0 && o + 2 < dlen) { + dst[o++] = ','; + dst[o++] = ' '; + } + for (const char *c = lab; *c && o + 1 < dlen; c++) + dst[o++] = *c; + shown++; + } + dst[o] = '\0'; + if (total > 3) { + size_t used = strlen(dst); + if (used < dlen) + snprintf(dst + used, dlen - used, " +%zu more", total - 3); + } +} + +/* Erase the previous render: move up `phys` physical rows and clear each, + * leaving the cursor at the start of the first cleared row. Mirrors the skills + * clearRender(); phys is a PHYSICAL row count (wrapping accounted for). */ +static void +episode_clear(size_t phys) +{ + if (phys == 0) + return; + char buf[32]; + int len = snprintf(buf, sizeof(buf), "\033[%zuA", phys); + if (len > 0) + tui_puts(buf); + for (size_t i = 0; i < phys; i++) + tui_puts("\033[2K\033[1B"); /* clear line, move down one */ + len = snprintf(buf, sizeof(buf), "\033[%zuA", phys); + if (len > 0) + tui_puts(buf); +} + +/* Width of the terminal in columns; default 80 on failure. */ +static size_t +tui_cols(void) +{ + struct winsize ws; + if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_col > 0) + return ws.ws_col; + return 80; +} + +/* Render the whole prompt for the given state. Returns the number of PHYSICAL + * rows written (so the next clear erases exactly that much), or 0 on OOM. */ +static size_t +episode_render(const char *message, const tui_episode_t *items, size_t n, + const unsigned char *sel, size_t cursor, size_t on_disk_count, + size_t total, size_t prev_phys, int state /*0 active,1 submit,2 cancel*/) +{ + size_t cols = tui_cols(); + line_buf_t lb = { NULL, 0, 0 }; + char line[1024]; + int oom = 0; + /* Declared before any `goto emit` so the jump skips no initialization. */ + size_t start = 0, end = 0, label_budget = 2, before = 0, after = 0; + + size_t rows = (size_t)tui_rows(); + + const char *icon = state == 0 ? SGR_GREEN G_ACTIVE SGR_RESET + : state == 2 ? SGR_RED G_CANCEL SGR_RESET + : SGR_GREEN G_SUBMIT SGR_RESET; + + /* Header: 'icon (M/N on disk)'. */ + snprintf(line, sizeof(line), "%s " SGR_BOLD "%s" SGR_RESET + " " SGR_DIM "(%zu/%zu on disk)" SGR_RESET, + icon, message ? message : "", on_disk_count, total); + + /* Chrome physical rows: header + hint (both measured, they wrap on a narrow + * term), two gutters, scroll, closer (1 each) and the selected summary (up + * to 2 rows). Each item row is <= cols so it never wraps. Clamp max_visible + * so chrome + window <= rows-1, keeping the block inside the viewport so + * episode_clear() never desyncs. */ + size_t chrome = tui_visual_rows_for_line(line, cols) + + tui_visual_rows_for_line(EPISODE_HINT, cols) + 6; + size_t budget = rows > 1 ? rows - 1 : 1; + size_t max_visible = budget > chrome ? budget - chrome : 1; + if (max_visible > n) + max_visible = n; + if (max_visible == 0) + max_visible = 1; + + if (lb_push(&lb, line) < 0) { oom = 1; goto emit; } + + if (state != 0) { /* submit / cancel: one summary/closer line */ + if (state == 1) { + char summary[512]; + build_summary(summary, sizeof(summary), items, n, sel); + snprintf(line, sizeof(line), + SGR_DIM G_BAR SGR_RESET " " SGR_GREEN + "Selected:" SGR_RESET " %s", summary); + } else { + snprintf(line, sizeof(line), + SGR_DIM G_BAR " Cancelled" SGR_RESET); + } + if (lb_push(&lb, line) < 0) { oom = 1; goto emit; } + goto emit; + } + + /* Hint line. */ + if (lb_push(&lb, EPISODE_HINT) < 0) { oom = 1; goto emit; } + + /* Empty gutter. */ + if (lb_push(&lb, SGR_DIM G_BAR SGR_RESET) < 0) { oom = 1; goto emit; } + + /* Windowed item rows. */ + start = tui_visible_start(cursor, n, max_visible); + end = start + max_visible; + if (end > n) + end = n; + + /* Budget for the label: columns minus the row prefix "│ ❯ ● " (~6 cells) + * minus a trailing " ✓" (2 cells) so on-disk rows don't wrap. */ + label_budget = cols > 10 ? cols - 8 : 2; + + for (size_t i = start; i < end; i++) { + const char *radio = sel[i] ? SGR_GREEN G_RADIO_ON SGR_RESET + : SGR_DIM G_RADIO_OFF SGR_RESET; + int is_cur = (i == cursor); + const char *prefix = is_cur ? SGR_CYAN G_CURSOR SGR_RESET : " "; + char lab[768]; + truncate_cells(lab, sizeof(lab), + items[i].label ? items[i].label : "", label_budget); + const char *tick = items[i].on_disk + ? " " SGR_GREEN G_TICK SGR_RESET : ""; + if (is_cur) + snprintf(line, sizeof(line), + SGR_DIM G_BAR SGR_RESET " %s %s " SGR_ULINE + "%s" SGR_RESET "%s", prefix, radio, lab, tick); + else + snprintf(line, sizeof(line), + SGR_DIM G_BAR SGR_RESET " %s %s %s%s", + prefix, radio, lab, tick); + if (lb_push(&lb, line) < 0) { oom = 1; goto emit; } + } + + /* Scroll indicator: '↑ X more ↓ Y more' when items are off-window. */ + before = start; + after = n - end; + if (before > 0 || after > 0) { + char ind[128]; + int o = 0; + ind[0] = '\0'; + if (before > 0) + o += snprintf(ind + o, sizeof(ind) - o, + "\xE2\x86\x91 %zu more", before); + if (after > 0) + snprintf(ind + o, sizeof(ind) - o, "%s\xE2\x86\x93 %zu more", + before > 0 ? " " : "", after); + snprintf(line, sizeof(line), SGR_DIM G_BAR " %s" SGR_RESET, ind); + if (lb_push(&lb, line) < 0) { oom = 1; goto emit; } + } + + /* Gutter + Selected summary + closer. */ + if (lb_push(&lb, SGR_DIM G_BAR SGR_RESET) < 0) { oom = 1; goto emit; } + { + char summary[512]; + build_summary(summary, sizeof(summary), items, n, sel); + size_t nsel = 0; + for (size_t i = 0; i < n; i++) + if (sel[i]) + nsel++; + if (nsel == 0) + snprintf(line, sizeof(line), + SGR_DIM G_BAR " Selected: (none)" SGR_RESET); + else + snprintf(line, sizeof(line), + SGR_DIM G_BAR SGR_RESET " " SGR_GREEN + "Selected:" SGR_RESET " %s", summary); + if (lb_push(&lb, line) < 0) { oom = 1; goto emit; } + } + if (lb_push(&lb, SGR_DIM G_CLOSER SGR_RESET) < 0) { oom = 1; goto emit; } + + emit: + episode_clear(prev_phys); + if (oom) { + lb_free(&lb); + return 0; + } + + /* Write all lines joined by CRLF; count physical rows for the next clear. + * Trailing CRLF leaves the cursor on a fresh line below the closer. */ + size_t phys = 0; + for (size_t i = 0; i < lb.n; i++) { + tui_puts(lb.v[i]); + tui_puts("\r\n"); + phys += tui_visual_rows_for_line(lb.v[i], cols); + } + lb_free(&lb); + return phys; +} + +/* Non-TTY fallback: numbered list with on-disk markers + an --episodes spec. */ +static int +episode_fallback(const char *message, const tui_episode_t *items, size_t n, + unsigned char *sel) +{ + if (message) + fprintf(stderr, "%s\n", message); + for (size_t i = 0; i < n; i++) + fprintf(stderr, " %zu) %s%s\n", i + 1, + items[i].label ? items[i].label : "", + items[i].on_disk ? " [on disk]" : ""); + fprintf(stderr, + "Select (e.g. 1,3-5,8 or 'all'; Enter = missing only; q to cancel): "); + fflush(stderr); + + char buf[256]; + if (!fgets(buf, sizeof(buf), stdin)) + return -1; + char *nl = strpbrk(buf, "\r\n"); + if (nl) + *nl = '\0'; + if (buf[0] == 'q' || buf[0] == 'Q') + return -1; + + /* Empty line: keep the preselected (missing) set. */ + const char *p = buf; + while (*p == ' ' || *p == '\t') + p++; + if (*p == '\0') { + int cnt = 0; + for (size_t i = 0; i < n; i++) + if (sel[i]) + cnt++; + return cnt; + } + + memset(sel, 0, n); + if (tui_apply_spec(buf, sel, n) != 0) + return -1; + int cnt = 0; + for (size_t i = 0; i < n; i++) + if (sel[i]) + cnt++; + return cnt; +} + +int +tui_episode_select(const char *message, const tui_episode_t *items, size_t n, + size_t on_disk_count, size_t total, size_t **out_idx) +{ + if (out_idx) + *out_idx = NULL; + if (!items || n == 0 || !out_idx) + return -1; + + unsigned char *sel = calloc(n, 1); + if (!sel) + return -2; + for (size_t i = 0; i < n; i++) + sel[i] = items[i].selected ? 1 : 0; + + const char *term = getenv("TERM"); + int is_dumb = !term || !*term || strcmp(term, "dumb") == 0; + if (!isatty(STDIN_FILENO) || !isatty(STDOUT_FILENO) || is_dumb || + tui_enter(1) != 0) { + int cnt = episode_fallback(message, items, n, sel); + if (cnt < 0) { + free(sel); + return -1; + } + int r = tui_collect(sel, n, out_idx); + free(sel); + return r; + } + + int cancelled = 0; + size_t cursor = 0; + size_t prev_phys = 0; + for (;;) { + prev_phys = episode_render(message, items, n, sel, cursor, + on_disk_count, total, prev_phys, 0); + tui_resized = 0; + + int key = tui_readkey(); + if (key == 'q') { + cancelled = 1; + break; + } else if (key == '\r') { + break; + } else if (key == 'j') { + if (cursor + 1 < n) + cursor++; + } else if (key == 'k') { + if (cursor > 0) + cursor--; + } else if (key == ' ') { + sel[cursor] = !sel[cursor]; + } else if (key == 'a') { /* toggle all on <-> off */ + int all = 1; + for (size_t i = 0; i < n; i++) + if (!sel[i]) { all = 0; break; } + memset(sel, all ? 0 : 1, n); + } else if (key == 'g') { + cursor = 0; + } else if (key == 'G') { + cursor = n - 1; + } + /* key == 0: redraw only (SIGWINCH or unknown sequence). */ + } + + /* Final frame: submit or cancel, then leave raw mode. */ + prev_phys = episode_render(message, items, n, sel, cursor, + on_disk_count, total, prev_phys, + cancelled ? 2 : 1); + tui_leave(); + + if (cancelled) { + free(sel); + return -1; + } + int r = tui_collect(sel, n, out_idx); + free(sel); + return r; +} diff --git a/src/tui.h b/src/tui.h index 4d786b2..605f94f 100644 --- a/src/tui.h +++ b/src/tui.h @@ -81,4 +81,42 @@ int tui_select_one(const char *title, const tui_item_t *items, size_t n); int tui_select_many(const char *title, const tui_item_t *items, size_t n, const unsigned char *preselect, size_t **out_idx); +/* One row of the episode multi-select: a label (e.g. "1 ep01-foo.mp4"), an + * initial selected flag, and whether the file already exists on disk. */ +typedef struct { + const char *label; /* borrowed, not owned */ + unsigned char selected; /* initial state (in); 1 = preselected */ + unsigned char on_disk; /* 1 = a matching file already exists */ +} tui_episode_t; + +/* Present a skills-styled episode multi-select under `message`. Items already on + * disk render with a green tick and start unselected; missing items start + * selected, so a plain Enter downloads exactly the missing episodes. Navigation: + * up/down or j/k, space toggles, 'a' toggles all, Enter confirms, q / Esc / + * Ctrl-C cancels. + * + * On confirm returns the number of chosen items (>= 0) and, if > 0, stores a + * malloc'd array of chosen 0-based indices in *out_idx (caller frees); Enter with + * nothing selected returns 0 with *out_idx == NULL. On cancel returns -1 with + * *out_idx == NULL; on allocation failure returns -2. + * + * `on_disk_count`/`total` feed the header "(M/N on disk)". Non-TTY / TERM=dumb + * falls back to a numbered prompt accepting an --episodes-style spec. The + * raw-mode terminal is always restored on every exit path including signals. */ +int tui_episode_select(const char *message, const tui_episode_t *items, + size_t n, size_t on_disk_count, size_t total, + size_t **out_idx); + +/* ---- pure helpers (exposed for unit testing the breakage-prone math) ---- */ + +/* Physical terminal rows one logical line occupies after soft-wrapping to + * `columns` cells. ANSI SGR escapes (\033[...m) are not counted toward width. + * Always >= 1. `columns` is clamped to >= 1. */ +size_t tui_visual_rows_for_line(const char *line, size_t columns); + +/* First visible row index for a window of `max_visible` rows over `len` items + * with the cursor at `cursor`: clamp(cursor - max_visible/2, 0, len-max_visible). + * Returns 0 when the whole list fits. */ +size_t tui_visible_start(size_t cursor, size_t len, size_t max_visible); + #endif /* FLUX_TUI_H */