// mcpp.ui verb-style colored status output.
//
// All user-visible status lines from CLI / fetcher / build go through
// here. TTY auto-detect; MCPP_NO_COLOR / --no-color disables colors.
module;
#include // fileno, stdout
export module mcpp.ui;
import std;
import mcpp.platform;
export namespace mcpp::ui {
// One-time initialization. Call once at program start.
void init();
// Force-disable color. Useful for --no-color flag handling.
void disable_color();
// Check if color is enabled.
bool is_color_enabled();
// Verb-style status ("Compiling foo v0.1.0" pattern).
// verb verb word, padded right-aligned in 12-char column
// message metadata after the verb
void status(std::string_view verb, std::string_view message);
// Cyan verb (Updating, Downloading, Cleaned).
void info(std::string_view verb, std::string_view message);
// Bold green Finished line.
// `descriptor` annotates the profile's actual effect (e.g. "optimized",
// "unoptimized + debuginfo"). Empty = print the profile name alone; callers
// that never resolved the profile knobs must not invent one.
void finished(std::string_view profile, std::chrono::milliseconds elapsed,
std::string_view descriptor = {});
// "warning:" / "error:" prefix lines (yellow / red).
void warning(std::string_view message);
void error(std::string_view message);
// Multi-line Rust-style diagnostic (M4 #8.1).
// Renders as:
//
// error[E0001]:
// --> path:line
// |
// |
// | ^^^^
// |
// = note:
// = help:
// = help: see `mcpp --explain E0001` for more details
//
// Empty fields are omitted.
struct Diagnostic {
std::string code; // e.g. "E0001" (optional)
std::string title;
std::filesystem::path path;
std::size_t line = 0;
std::size_t column = 0;
std::string sourceLine; // optional snippet
std::string spanMessage; // points at column
std::vector notes;
std::vector helps;
};
void diagnostic(const Diagnostic& d);
// Plain output (no verb), respecting -q flag.
void plain(std::string_view message);
// Flush stdout. Every stdout-writing function above already calls this, so
// callers only need it when they wrote to stdout directly (std::println) and
// want that line visible now rather than whenever the libc buffer happens to
// fill. main() also sets stdout line-buffered, which covers the POSIX
// platforms; this is what makes the guarantee hold on Windows too, where
// MSVCRT treats _IOLBF as _IOFBF. Progress-driven output must be visible while
// the process is still running: a build that is killed mid-flight is exactly
// when its last lines matter most.
void flush();
// Make stdout line-buffered. Call once, before any output. See main() for why
// this is not left to the libc default: the default block size is a different
// number on every platform (musl 1024, Apple libc st_blksize = 65536 on a pipe,
// MSVCRT 4096), so identical output becomes visible at wildly different times
// and not at all if the process is killed before its buffer fills.
void set_line_buffered();
// --- progress bar (single-line, \r-rewritten) ---
class ProgressBar {
public:
ProgressBar(std::string_view verb, std::string_view label);
~ProgressBar();
ProgressBar(const ProgressBar&) = delete;
ProgressBar& operator=(const ProgressBar&) = delete;
// Update progress; renders only once per ~50ms to avoid jitter.
void update(std::size_t percent);
// elapsed_sec, when > 0, drives a `~X.Y MB/s` average-rate suffix.
void update_bytes(std::size_t current_bytes, std::size_t total_bytes,
double elapsed_sec = 0.0);
// Connecting / pre-sizing phase: the total size isn't known yet (the
// downloader reports totalBytes==0 during DNS/TLS/redirect before the
// transfer's Content-Length is available, and some servers stream with no
// length at all). Renders a swept (indeterminate) bar plus a ticking
// `connecting Ns` / `X.Y MB Ns` suffix so the line never freezes.
void update_indeterminate(std::size_t current_bytes,
double elapsed_sec = 0.0);
// Finish: replaces progress with final-state line.
void finish();
void finish_with(std::string_view final_message);
private:
void render_line(std::size_t percent, const std::string& info_text);
void render_line_swept(std::size_t frame, const std::string& info_text);
std::string verb_;
std::string label_;
std::chrono::steady_clock::time_point lastDraw_;
bool finished_ = false;
};
// --- download progress (centralized) ---
//
// One file's download state, decoded from xlings' NDJSON `download_progress`
// `files[]` entries. A neutral struct so this UI module stays free of any
// fetcher / config dependency (those import each other and would cycle).
struct DownloadFile {
std::string name;
std::size_t downloaded = 0;
std::size_t total = 0; // 0 = size not known yet (connecting)
bool started = false;
bool finished = false;
};
// Centralized renderer for a streaming multi-file download. Owns the
// ProgressBar plus the "which file is active / which are done" bookkeeping,
// and decides per frame whether to draw a percentage bar (size known) or a
// swept/indeterminate bar (still connecting). Absorbs two xlings quirks:
// each file's `finished=true` is reported twice, and `files[]` reshuffles
// between events. Feed it each event's `files[]` snapshot + cumulative
// `elapsedSec`; it is the single place mcpp turns download events into UI.
class DownloadProgress {
public:
DownloadProgress() = default;
~DownloadProgress();
DownloadProgress(const DownloadProgress&) = delete;
DownloadProgress& operator=(const DownloadProgress&) = delete;
void update(std::span files, double elapsed_sec);
void finish(); // finish the active bar if any (idempotent)
private:
std::optional bar_;
std::string active_;
std::unordered_set finished_;
};
// --- quiet flag (suppresses status / info / finished) ---
void set_quiet(bool q);
bool is_quiet();
// --- path display ---
//
// Path shortening for status output. Long absolute paths under the project
// root, MCPP_HOME, or the user's home directory get rewritten to short
// relative forms so the user can see _what_ rather than _where_.
//
// Substitution rules (most specific wins):
// /x/y/z x/y/z (project-relative)
// /x/y/z @mcpp/x/y/z
// /x/y/z ~/x/y/z
// anything else absolute path
//
// `project_root` is optional leave empty when the caller doesn't have a
// project context (e.g. for `mcpp self env`).
struct PathContext {
std::filesystem::path project_root;
std::filesystem::path mcpp_home;
std::filesystem::path home;
};
std::string shorten_path(const std::filesystem::path& p, const PathContext& ctx);
} // namespace mcpp::ui
namespace mcpp::ui {
namespace {
bool g_color = false;
bool g_quiet = false;
bool g_inited = false;
constexpr std::string_view kReset = "\033[0m";
constexpr std::string_view kBold = "\033[1m";
constexpr std::string_view kGreen = "\033[32m";
constexpr std::string_view kBrightGreen= "\033[92m";
constexpr std::string_view kCyan = "\033[36m";
constexpr std::string_view kBrightCyan = "\033[96m";
constexpr std::string_view kYellow = "\033[33m";
constexpr std::string_view kRed = "\033[31m";
constexpr std::string_view kBrightRed = "\033[91m";
bool detect_color() {
if (auto* e = std::getenv("MCPP_NO_COLOR"); e && *e == '1') return false;
if (auto* e = std::getenv("NO_COLOR"); e && *e) return false;
return mcpp::platform::terminal::is_tty();
}
std::string with_color(std::string_view code, std::string_view text) {
if (!g_color) return std::string(text);
std::string out;
out.reserve(code.size() + text.size() + kReset.size());
out.append(code).append(text).append(kReset);
return out;
}
std::string verb_padded(std::string_view verb) {
constexpr std::size_t W = 12;
if (verb.size() >= W) return std::string(verb);
std::string s(W - verb.size(), ' ');
s.append(verb);
return s;
}
} // namespace
void init() {
if (g_inited) return;
g_color = detect_color();
g_inited = true;
}
void disable_color() { g_color = false; }
bool is_color_enabled() { return g_color; }
void set_quiet(bool q) { g_quiet = q; }
bool is_quiet() { return g_quiet; }
void flush() { std::fflush(stdout); }
void set_line_buffered() {
#if defined(_WIN32)
// Not on Windows, and not as a preference. The UCRT documents setvbuf's
// size as `2 width) filled = width;
std::string bar = "[";
for (std::size_t i = 0; i < filled; ++i) bar += "=";
if (filled < width) bar += ">";
for (std::size_t i = filled + 1; i < width; ++i) bar += " ";
bar += "]";
return bar;
}
std::string fmt_bytes(std::size_t b) {
if (b < 1024) return std::format("{} B", b);
if (b < 1024 * 1024) return std::format("{} KB", b / 1024);
if (b < 1024UL*1024*1024) return std::format("{:.1f} MB", static_cast(b) / (1024.0*1024.0));
return std::format("{:.2f} GB", static_cast(b) / (1024.0*1024.0*1024.0));
}
// Best-effort terminal width. Tries TIOCGWINSZ first; on failure (e.g.,
// stdout is a pipe) honours $COLUMNS so users can clamp the width
// manually for testing or when running under CI loggers that don't
// propagate winsize. Falls back to 80 cols.
//
// 80 is the right safe default for a "fixed-shape" status line we'd
// rather collapse the bar than wrap into a second row that `\r\033[2K`
// can't clean up later.
std::size_t terminal_cols() {
return mcpp::platform::terminal::cols();
}
// Truncate a "visible" string (no ANSI codes inside) to `max` chars, replacing
// the last char with `` when we cut. Used to keep the progress line under
// terminal width without wrapping into a second row.
std::string trunc_visible(std::string s, std::size_t max) {
if (s.size() 0) {
std::size_t period = span * 2;
std::size_t p = frame % period;
pos = (p = budget) {
// Truly tiny terminal drop the bar entirely.
auto labelBudget = budget > kVerbWidth + 1 + info_text.size() + 1
? budget - kVerbWidth - 1 - info_text.size() - 1
: 0;
auto lbl = trunc_visible(label, labelBudget);
if (g_color) {
std::print("\r\033[2K{}{}{}{} {} {}",
kBold, kBrightCyan, verb_padded(verb), kReset,
lbl, info_text);
} else {
std::print("\r\033[2K{} {} {}", verb_padded(verb), lbl, info_text);
}
std::fflush(stdout);
return;
}
auto contentBudget = budget - fixed; // barInner + visible-label-cols
std::size_t barW = std::min(kBarMax, contentBudget);
std::size_t labelMax = contentBudget - barW;
if (barW < kBarMin && labelMax > 0) {
auto steal = std::min(kBarMin - barW, labelMax);
barW += steal;
labelMax -= steal;
}
auto bar = makeBar(barW);
auto lbl = trunc_visible(label, labelMax);
if (g_color) {
std::print("\r\033[2K{}{}{}{} {} {} {}",
kBold, kBrightCyan, verb_padded(verb), kReset,
lbl, bar, info_text);
} else {
std::print("\r\033[2K{} {} {} {}",
verb_padded(verb), lbl, bar, info_text);
}
std::fflush(stdout);
}
} // namespace
ProgressBar::ProgressBar(std::string_view verb, std::string_view label)
: verb_(verb), label_(label),
lastDraw_(std::chrono::steady_clock::now() - std::chrono::seconds(1))
{}
ProgressBar::~ProgressBar() {
if (!finished_) finish();
}
// Render a single progress-bar frame. The verb is drawn separately (with
// optional color) so we can keep ANSI escapes out of the truncation budget.
// `cols` is the available terminal width; `info_text` is the trailing
// "%" / "X MB / Y MB / Z MB/s" suffix; `pct` drives the bar fill.
//
// Layout (visible chars only):
//
//
// The bar shrinks first when we run out of room, then `label` is truncated
// with an ellipsis. Result is always cols-1 chars so a `\r\033[2K{...}`
// write never wraps into a second row.
// Layout (visible chars only): .
// The width budgeting lives in draw_status_line(); this just supplies a
// percentage-fill bar for the negotiated inner width.
void ProgressBar::render_line(std::size_t pct, const std::string& info_text)
{
init();
draw_status_line(verb_, label_,
[pct](std::size_t w) { return render_bar(pct, w); },
info_text);
}
// Same layout, but an animated swept/indeterminate bar (used while the total
// download size is still unknown).
void ProgressBar::render_line_swept(std::size_t frame, const std::string& info_text)
{
init();
draw_status_line(verb_, label_,
[frame](std::size_t w) { return render_bar_swept(frame, w); },
info_text);
}
void ProgressBar::update(std::size_t percent) {
if (g_quiet || finished_) return;
auto now = std::chrono::steady_clock::now();
if (now - lastDraw_ < std::chrono::milliseconds(80) && percent < 100) return;
lastDraw_ = now;
render_line(percent, std::format("{}%", percent));
}
void ProgressBar::update_bytes(std::size_t current, std::size_t total,
double elapsed_sec) {
if (g_quiet || finished_) return;
auto now = std::chrono::steady_clock::now();
auto pct = total ? (current * 100 / total) : 0;
if (pct > 100) pct = 100;
// Same throttle as update(): one render per ~80ms unless we hit 100%.
if (now - lastDraw_ < std::chrono::milliseconds(80) && pct < 100) return;
lastDraw_ = now;
auto info = std::format("{} / {}", fmt_bytes(current), fmt_bytes(total));
// Average rate since the download started. xlings only ships the
// cumulative `elapsedSec`, so this is "since-start" rather than
// a sliding-window instantaneous speed accurate enough for UX.
if (elapsed_sec > 0.5 && current > 0) {
auto rate = static_cast(
static_cast(current) / elapsed_sec);
info += std::format(" {}/s", fmt_bytes(rate));
}
render_line(pct, info);
}
void ProgressBar::update_indeterminate(std::size_t current_bytes,
double elapsed_sec) {
if (g_quiet || finished_) return;
auto now = std::chrono::steady_clock::now();
// Same ~80ms throttle as update_bytes(); there is no "100%" early-out here
// because there is no known total.
if (now - lastDraw_ < std::chrono::milliseconds(80)) return;
lastDraw_ = now;
// Before any byte arrives we only have "connecting"; once the body starts
// streaming (no Content-Length) show the running byte count instead. The
// ticking elapsed-seconds suffix proves the line is alive either way.
std::string info = current_bytes > 0 ? fmt_bytes(current_bytes)
: std::string{"connecting"};
if (elapsed_sec > 0)
info += std::format(" {:.0f}s", elapsed_sec);
auto frame = static_cast(elapsed_sec * 6.0);
render_line_swept(frame, info);
}
void ProgressBar::finish() {
if (finished_) return;
finished_ = true;
if (g_quiet) return;
// Clear the line and re-emit as a static info line.
std::print("\r\033[2K");
info(verb_, label_);
}
void ProgressBar::finish_with(std::string_view final_message) {
if (finished_) return;
finished_ = true;
if (g_quiet) return;
std::print("\r\033[2K");
info(verb_, final_message);
}
// --- DownloadProgress ---
DownloadProgress::~DownloadProgress() { finish(); }
void DownloadProgress::finish() {
if (bar_) bar_->finish();
bar_.reset();
active_.clear();
}
void DownloadProgress::update(std::span files,
double elapsed_sec) {
if (files.empty()) return;
// 1. Retire newly-finished entries. Each file's finished=true is reported
// twice (xlings quirk); the `finished_` set dedupes that and the case
// where the same file reappears at a different slot in a later event.
for (auto& f : files) {
if (finished_.contains(f.name)) continue;
if (!f.finished) continue;
if (active_ == f.name) {
if (bar_) bar_->finish();
bar_.reset();
active_.clear();
}
finished_.insert(f.name);
}
// 2. Pick what to display: keep showing `active_` if it's still streaming,
// else the first started+unfinished file. This stops the bar from
// flickering between names when files[] reshuffles across events during
// a multi-package install.
const DownloadFile* current = nullptr;
for (auto& f : files) {
if (f.name == active_ && !f.finished && !finished_.contains(f.name)) {
current = &f;
break;
}
}
if (!current) {
for (auto& f : files) {
if (finished_.contains(f.name)) continue;
if (f.started && !f.finished) { current = &f; break; }
}
}
if (!current) return;
if (current->name != active_) {
if (bar_) bar_->finish();
active_ = current->name;
bar_.emplace("Downloading", current->name);
}
if (current->total > 0) {
bar_->update_bytes(current->downloaded, current->total, elapsed_sec);
} else {
// Size not known yet (connecting / no Content-Length): keep the line
// animated instead of frozen.
bar_->update_indeterminate(current->downloaded, elapsed_sec);
}
}
std::string shorten_path(const std::filesystem::path& p, const PathContext& ctx) {
namespace fs = std::filesystem;
// Use a pure string-prefix comparison rather than fs::relative
// fs::relative internally canonicalises both arguments, which would
// resolve symlinks. We want to display the path the user thinks they
// are working with (e.g. `/registry/data/xpkgs/` even
// when xpkgs/ is symlinked to a system xlings cache), so we keep
// every comparison purely lexical.
auto can = p.lexically_normal().generic_string();
auto rel_to = [&](const fs::path& base) -> std::optional {
if (base.empty()) return std::nullopt;
auto bs = base.lexically_normal().generic_string();
// Strip trailing slashes on the base so "/x/y" and "/x/y/" match
// the same set of candidate paths.
while (!bs.empty() && bs.back() == '/') bs.pop_back();
if (bs.empty()) return std::nullopt;
if (can == bs) return std::string{};
if (can.size() > bs.size()
&& can.compare(0, bs.size(), bs) == 0
&& can[bs.size()] == '/') {
return can.substr(bs.size() + 1);
}
return std::nullopt;
};
if (auto r = rel_to(ctx.project_root); r) {
// Project-relative print bare ("target/release/foo"), no prefix.
return r->empty() ? std::string{"."} : *r;
}
if (auto r = rel_to(ctx.mcpp_home); r) {
return r->empty() ? std::string{"@mcpp"} : "@mcpp/" + *r;
}
if (auto r = rel_to(ctx.home); r) {
return r->empty() ? std::string{"~"} : "~/" + *r;
}
return can;
}
} // namespace mcpp::ui