From 1e6f7f0ae6f9cab1986d0247c2b2e12963e647c1 Mon Sep 17 00:00:00 2001 From: Tomas Date: Thu, 27 Nov 2025 03:11:44 +0100 Subject: [PATCH] Added pretty download UI Closes #1 --- meson.build | 1 + source/lunch/color.d | 86 +++++++++++++++++++++++++++++ source/lunch/http.d | 8 +-- source/lunch/term.d | 39 ++++++++++++++ source/lunch/update.d | 123 +++++++++++++++++++++++++++++++++++------- 5 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 source/lunch/term.d diff --git a/meson.build b/meson.build index c59925a..b45a282 100644 --- a/meson.build +++ b/meson.build @@ -12,6 +12,7 @@ lunch_src = files( 'source/lunch/http.d', 'source/lunch/launch.d', 'source/lunch/logger.d', + 'source/lunch/term.d', 'source/lunch/ui.d', 'source/lunch/update.d', ) diff --git a/source/lunch/color.d b/source/lunch/color.d index 51eed05..d27d6b3 100644 --- a/source/lunch/color.d +++ b/source/lunch/color.d @@ -1,6 +1,92 @@ module lunch.color; +string black() +{ + return "\x1B[30m"; +} + +string red() +{ + return "\x1B[31m"; +} + +string green() +{ + return "\x1B[32m"; +} + +string yellow() +{ + return "\x1B[33m"; +} + +string blue() +{ + return "\x1B[34m"; +} + +string magenta() +{ + return "\x1B[35m"; +} + +string cyan() +{ + return "\x1B[36m"; +} + +string white() +{ + return "\x1B[37m"; +} + +string brightBlack() +{ + return "\x1B[90m"; +} + +string brightRed() +{ + return "\x1B[91m"; +} + +string brightGreen() +{ + return "\x1B[92m"; +} + +string brightYellow() +{ + return "\x1B[93m"; +} + +string brightBlue() +{ + return "\x1B[94m"; +} + +string brightMagenta() +{ + return "\x1B[95m"; +} + +string brightCyan() +{ + return "\x1B[96m"; +} + +string brightWhite() +{ + return "\x1B[97m"; +} + +string reset() +{ + return "\x1B[0m"; +} + + const(char)[] black(const(char)[] str) { return "\x1B[30m" ~ str ~ "\x1B[0m"; diff --git a/source/lunch/http.d b/source/lunch/http.d index f892b96..bdbe77f 100644 --- a/source/lunch/http.d +++ b/source/lunch/http.d @@ -82,7 +82,7 @@ T[] put(Conn = AutoProtocol, T = char, PutUnit)(const(char)[] url, const(PutUnit } } -private void _download(const(char)[] url, string file) +private void _download(const(char)[] url, string file, void delegate(size_t size) progress) { scope (failure) if (exists(file)) @@ -94,16 +94,18 @@ private void _download(const(char)[] url, string file) http.onReceive = (ubyte[] data) { handle.rawWrite(data); + if (progress) + progress(data.length); return data.length; }; http.perform(); } -void download(const(char)[] url, string file) +void download(const(char)[] url, string file, void delegate(size_t size) progress = null) { try { - _retry!_download(url, file); + _retry!_download(url, file, progress); infof("DL %s", url); } catch (CurlException ex) diff --git a/source/lunch/term.d b/source/lunch/term.d new file mode 100644 index 0000000..2668e7d --- /dev/null +++ b/source/lunch/term.d @@ -0,0 +1,39 @@ +module lunch.term; + +version (Windows) +{ + import core.sys.windows.windows; +} + + +struct TermSize +{ + int width, height; +} + +TermSize termSize() +{ + version (Windows) + { + CONSOLE_SCREEN_BUFFER_INFO csbi; + int columns, rows; + + GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), &csbi); + return TermSize( + csbi.srWindow.Right - csbi.srWindow.Left + 1, + csbi.srWindow.Bottom - csbi.srWindow.Top + 1, + ); + } + + return TermSize(80, 25); // Default for windows cmd +} + +int termWidth() +{ + return termSize().width; +} + +int termHeight() +{ + return termSize().height; +} \ No newline at end of file diff --git a/source/lunch/update.d b/source/lunch/update.d index 62e422f..8ab038d 100644 --- a/source/lunch/update.d +++ b/source/lunch/update.d @@ -1,24 +1,27 @@ module lunch.update; import std.datetime; -import std.stdio : writefln, File; +import std.stdio; import std.traits; import std.json; import std.conv; -import std.file; +import std.file : dirEntries; import std.path; import std.file; import std.math; import std.string; import std.digest.sha; import std.array; +import std.format; import std.algorithm; import std.parallelism; +import std.datetime.stopwatch; import lunch.conf; +import lunch.term; +import lunch.color; import lunch.http; import lunch.logger; - private struct RemoteFile { string file; @@ -87,6 +90,7 @@ private struct Action private struct Actions { Action[] actions; + ulong total_download_size; alias this = actions; } @@ -100,7 +104,6 @@ private immutable(Local) _local; private bool _actions_set = false; private immutable(Actions) _actions; - private JSONValue[string] safe_object(JSONValue value) @trusted { if (value.type != JSONType.object) @@ -221,13 +224,11 @@ void wrapJSON(T)(ref T wrapper, JSONValue[string] json, bool ignore_missing = fa }} } - private string workdir(string path) { return chainPath(config.updater.workdir, path).array; } - private void touch(string path, long mtime = Clock.currTime().toUnixTime) { infof("Touching %s", path); @@ -236,7 +237,6 @@ private void touch(string path, long mtime = Clock.currTime().toUnixTime) setTimes(path, old_atime, SysTime.fromUnixTime(mtime)); } - private immutable(Remote) remote() { if (_remote_set) @@ -263,7 +263,6 @@ private immutable(Remote) remote() return _remote; } - private immutable(Local) local() { if (_local_set) @@ -302,7 +301,6 @@ private immutable(Local) local() return _local; } - private immutable(Actions) actions() { if (_actions_set) @@ -346,6 +344,7 @@ private immutable(Actions) actions() if (!this_loc) { act ~= this_act; + act.total_download_size += this_rem.size; continue; } @@ -356,6 +355,7 @@ private immutable(Actions) actions() if (this_loc.hash != this_rem.hash || this_loc.size != this_rem.size) { act ~= this_act; + act.total_download_size += this_rem.size; } else { @@ -378,7 +378,6 @@ private immutable(Actions) actions() return _actions; } - public bool updateAvailable() { if (actions.length != 0) @@ -391,16 +390,77 @@ public void update() { info("Updating"); - scope (exit) cast() _actions = []; + scope (exit) cast() _actions = Actions(); - const dl_total = actions.actions.count!"a.what == b"(Action.download); - const dl_pad = cast(int)log10(cast(float)dl_total) + 1; - size_t dl_count = 0; + auto writeSW = StopWatch(AutoStart.yes); + auto speedSW = StopWatch(AutoStart.yes); + const dl_total = actions.actions.count!"a.what == b"(Action.download); + const dl_pad = cast(int)log10(cast(float)dl_total) + 1; + uint dl_count = 0; + ulong dl_size = 0; + const dl_spad = cast(int)log10(cast(float)actions.total_download_size) + 1; + ulong dl_speed = 0; + ulong dl_speed_old_size = 0; const old_defaultpool = defaultPoolThreads; scope (exit) defaultPoolThreads = old_defaultpool; defaultPoolThreads = config.updater.parallel_downloads.to!uint - 1; + auto writeProgress = delegate() + { + string numbers = format( + "[%s%*d%s|%s%d%s]", + magenta, dl_spad, dl_size, reset, + magenta, actions.total_download_size, reset, + ); + + int barSpace = termWidth - dl_spad*2 - 3 - 2 - 10 - 11; + float progress = dl_size.to!float / actions.total_download_size; + long filledSpace = lround(progress * barSpace); + long emptySpace = lround((1.0 - progress) * barSpace); + + string progress_bar = "["~yellow; + for (int n = 0; n < filledSpace; n++) + progress_bar ~= '='; + for (int n = 0; n < emptySpace; n++) + progress_bar ~= ' '; + progress_bar ~= reset~"]"; + + string percent = format( + "[%7.2f%%]", progress * 100 + ); + + int speed_rank = 0; + float speed = dl_speed.to!float; + + for (speed_rank = 0; speed >= 1000; speed_rank += 1) + speed /= 1000; + + string unit = [ + " B", + "KB", + "MB", + "GB", + "TB", + "PB", + "EB", + "ZB", + "YB", + "RB", + "QB", + ][speed_rank]; + + string download_speed = (speed < 100) + ? format( + "[%s%4.1f %s%s/s]", cyan, speed, reset, unit + ) + : format( + "[%s%5.1f%s%s/s]", cyan, speed, reset, unit + ); + + writef("\r%s%s%s%s\b", numbers, download_speed, progress_bar, percent); + }; + foreach (action; parallel(actions.actions, 1)) final switch(action.what) { @@ -409,13 +469,36 @@ public void update() remove(action.file); break; case Action.download: + info("Update %s", action.file); mkdirRecurse(dirName(action.file)); - synchronized - { - dl_count += 1; - writefln("[%*d|%*d] %s", dl_pad, dl_count, dl_pad, dl_total, action.file); - } - download(action.url, action.file); + + download(action.url, action.file, (size_t amount){ + synchronized + { + dl_size += amount; + if (speedSW.peek.total!"msecs" >= 1000) + { + long time = speedSW.peek.total!"msecs"; + dl_speed = lround((dl_size - dl_speed_old_size) * time.to!float / 1000); + dl_speed_old_size = dl_size; + speedSW.reset(); + } + if (writeSW.peek.total!"msecs" < 100) + return; + writeSW.reset(); + } + writeProgress(); + }); + + synchronized dl_count += 1; + writefln( + "\33[2K\r[%s%*d%s|%s%d%s] %s", + cyan, dl_pad, dl_count, reset, + cyan, dl_total, reset, + action.file.replace("/", dirSeparator).green, + ); + writeProgress(); + touch(action.file, action.mtime); break; }