diff --git a/include/util/format.hpp b/include/util/format.hpp index 14bf3ef3..7addb045 100644 --- a/include/util/format.hpp +++ b/include/util/format.hpp @@ -23,30 +23,51 @@ class pow_format { namespace fmt { template <> struct formatter { - char spec = 0; - int width = 0; + char spec = 0; // alignment: '>', '<', '=' (0 = none) + int width = 0; // width digits; enforced only when scale_spec != 0 + char scale_spec = 0; // forced scale: 0 = auto, else one of '#','k','M','G','T','P' + char unit_pref = 0; // unit tri-state: 0 = default, 'u' = hide, 'U' = show + char base_pref = 0; // base tri-state: 0 = call-site, 'b' = decimal, 'B' = binary + bool force_int = false; // 'i': force integer display template constexpr auto parse(ParseContext& ctx) -> decltype(ctx.begin()) { auto it = ctx.begin(), end = ctx.end(); if (it != end && *it == ':') ++it; - if (it && (*it == '>' || *it == '<' || *it == '=')) { + if (it != end && (*it == '>' || *it == '<' || *it == '=')) { spec = *it; ++it; } - if (it == end || *it == '}') return it; - if ('0' <= *it && *it <= '9') { - // We ignore it for now, but keep it for compatibility with - // existing configs where the format for pow_format'ed numbers was - // 'string' and specifications such as {:>9} were valid. - // The rationale for ignoring it is that the only reason to specify - // an alignment and a with is to get a fixed width bar, and ">" is - // sufficient in this implementation. + // Consume scale/flag modifiers and the width in any order, until '}' or end. + // The width digits (parsed but only enforced when a scale is forced — see + // format()) may appear anywhere among the modifiers, so both {:=#3} and the + // more natural {:=3#} are accepted. On an unrecognised char we stop and let + // fmt raise its usual error. + while (it != end && *it != '}') { + char c = *it; + if (c == '#' || c == 'k' || c == 'M' || c == 'G' || c == 'T' || c == 'P') { + scale_spec = c; + ++it; + } else if (c == 'u' || c == 'U') { + unit_pref = c; + ++it; + } else if (c == 'b' || c == 'B') { + base_pref = c; + ++it; + } else if (c == 'i') { + force_int = true; + ++it; + } else if ('0' <= c && c <= '9') { + // Width kept for compatibility with existing configs such as {:>9}; only + // enforced (fixed field + '#' overflow) when a scale is forced. #if FMT_VERSION < 80000 - width = parse_nonnegative_int(it, end, ctx); + width = parse_nonnegative_int(it, end, ctx); #else - width = detail::parse_nonnegative_int(it, end, -1); + width = detail::parse_nonnegative_int(it, end, -1); #endif + } else { + break; + } } return it; } @@ -54,49 +75,100 @@ struct formatter { template auto format(const pow_format& s, FormatContext& ctx) const -> decltype(ctx.out()) { const char* units[] = {"", "k", "M", "G", "T", "P", nullptr}; + const int max_pow = 5; // last valid index in units[] - auto base = s.binary_ ? 1024ull : 1000ll; + // Effective base: 'b'/'B' override the call-site binary_. + bool binary = base_pref == 'B' ? true : base_pref == 'b' ? false : s.binary_; + auto base = binary ? 1024ull : 1000ll; auto div = 1ll; auto fraction = (double)s.val_; int pow; - for (pow = 0; units[pow + 1] != nullptr && fraction / base >= 1; ++pow) { - fraction /= base; - div *= base; + if (scale_spec != 0) { + // Forced scale: map the char to a fixed index into units[]. + switch (scale_spec) { + case 'k': + pow = 1; + break; + case 'M': + pow = 2; + break; + case 'G': + pow = 3; + break; + case 'T': + pow = 4; + break; + case 'P': + pow = 5; + break; + default: + pow = 0; + break; // '#' -> base scale + } + if (pow > max_pow) pow = max_pow; + for (int i = 0; i < pow; ++i) div *= base; + fraction /= div; + } else { + for (pow = 0; units[pow + 1] != nullptr && fraction / base >= 1; ++pow) { + fraction /= base; + div *= base; + } } - auto fixed_precision = (s.skip_decimal_ && ((s.val_ % div) == 0)) ? 0 : 1; - auto number_width = 3 + fixed_precision // coeff in {:.{fixed_precision}f} format - + (fixed_precision != 0) // float dot - + s.binary_; // potential digit before the decimal point - auto max_width = number_width + 1 // prefix from units array - + s.binary_ // for the 'i' in GiB. - + s.unit_.length(); + // Precision: 'i' forces 0; otherwise 1 (or 0 when skip_decimal_ divides + // evenly). min_pow_for_decimal_ keeps its default-branch-only effect. + int precision = force_int ? 0 : (s.skip_decimal_ && ((s.val_ % div) == 0)) ? 0 : 1; + if (!force_int && scale_spec == 0 && pow < s.min_pow_for_decimal_) precision = 0; + + // Unit visibility: default on for auto scale, off for a forced scale; 'u'/'U' + // override. The binary 'i' is part of the scale prefix, so the unit is just + // unit_. + bool hide_unit = unit_pref == 'u' || (unit_pref == 0 && scale_spec != 0); + + // Scale prefix (letter + binary 'i'), suppressed entirely when a scale is + // forced. + std::string prefix = + scale_spec != 0 ? "" : std::string(units[pow]) + ((binary && pow) ? "i" : ""); + std::string unit = hide_unit ? "" : s.unit_; + + auto number_width = 3 + precision // coeff in {:.{precision}f} format + + (precision != 0) // float dot + + binary; // potential digit before the decimal point + // In auto mode the prefix column is always reserved (letter + optional 'i'), + // matching the historical fixed max_width even at base scale (the '=' padding + // fills the gap). A forced scale drops the prefix column entirely. + auto prefix_col = scale_spec != 0 ? 0 : 1 + binary; + auto max_width = number_width + prefix_col + unit.length(); + + // The numeric coefficient string. When a scale is forced with a width and the + // number does not fit, it overflows to '#' (spreadsheet-style). + bool fixed_num = scale_spec != 0 && width > 0; + std::string number = fmt::format("{:.{}f}", fraction, precision); + if (fixed_num && (int)number.length() > width) number = std::string(width, '#'); + + // Base-scale compensation for the '=' column-align: only in auto mode, where + // the absent prefix (and binary 'i') would otherwise shift the unit column. + const char* padding = (scale_spec == 0 && pow == 0) ? (binary ? " " : " ") : ""; - const char* format; - std::string string; switch (spec) { - case '>': - return fmt::format_to(ctx.out(), "{:>{}}", fmt::format("{}", s), max_width); - case '<': - return fmt::format_to(ctx.out(), "{:<{}}", fmt::format("{}", s), max_width); case '=': - format = "{coefficient:<{number_width}.{fixed_precision}f}{padding}{prefix}{unit}"; - break; + // Column-align: left-justify the coefficient within its column, then pad + // so the prefix/unit line up across values of different magnitude. + return fmt::format_to(ctx.out(), "{:<{}}{}{}{}", number, fixed_num ? width : number_width, + padding, prefix, unit); + case '>': + case '<': case 0: - default: - format = pow < s.min_pow_for_decimal_ ? "{coefficient:.0f}{prefix}{unit}" - : "{coefficient:.{fixed_precision}f}{prefix}{unit}"; - break; + default: { + // Right-justify the numeric field to the fixed width when forced. + std::string body = + (fixed_num ? fmt::format("{:>{}}", number, width) : number) + prefix + unit; + if (spec == '>') return fmt::format_to(ctx.out(), "{:>{}}", body, max_width); + if (spec == '<') return fmt::format_to(ctx.out(), "{:<{}}", body, max_width); + return fmt::format_to(ctx.out(), "{}", body); + } } - return fmt::format_to( - ctx.out(), fmt::runtime(format), fmt::arg("coefficient", fraction), - fmt::arg("fixed_precision", fixed_precision), fmt::arg("number_width", number_width), - fmt::arg("prefix", std::string() + units[pow] + ((s.binary_ && pow) ? "i" : "")), - fmt::arg("unit", s.unit_), - fmt::arg("padding", pow ? "" - : s.binary_ ? " " - : " ")); } }; diff --git a/man/waybar-disk.5.scd b/man/waybar-disk.5.scd index 16e0d360..d36fb608 100644 --- a/man/waybar-disk.5.scd +++ b/man/waybar-disk.5.scd @@ -146,6 +146,29 @@ Addressed by *disk* *{specific_free}*: Amount of available disk space for normal users in a specific unit. Defaults to bytes. +# NUMBER FORMAT MODIFIERS + +*{total}*, *{used}* and *{free}* auto-scale with a binary prefix (KiB, GiB, …). +Their rendering can be tuned with fmt-style modifiers, e.g. *"{free:>}"* or +*"{free:G}"*, combined in any order: + +*<*, *=*, *>*: Alignment/padding (left, column-align, right). + +*u* / *U*: Hide (*u*) or show (*U*) the unit suffix. Shown by default with an auto +scale, hidden by default when a scale is forced. + +*#*, *k*, *M*, *G*, *T*, *P*: Force a fixed scale instead of auto-selecting +(*#* = base scale). Forcing a scale hides the scale prefix and, by default, the +unit. E.g. *"{free:G}"* always shows gibibytes. + +*i*: Force integer display (no decimals). + +*b* / *B*: Force decimal base 1000 (*b*) or binary base 1024 (*B*); disk values +are binary by default. + +A trailing number is a fixed width for the coefficient when a scale is forced; +overflow is shown as *#* characters (e.g. *"{free:=3#}"* → *###*). + # EXAMPLES ``` diff --git a/man/waybar-network.5.scd b/man/waybar-network.5.scd index efeed0ce..b950d431 100644 --- a/man/waybar-network.5.scd +++ b/man/waybar-network.5.scd @@ -214,6 +214,34 @@ Addressed by *network* *{icon}*: Icon, as defined in *format-icons*. +# NUMBER FORMAT MODIFIERS + +The bandwidth/bitrate replacements above (*{bandwidth\*}*, *{txBitrate}*, +*{rxBitrate}*, *{linkSpeed}*) auto-scale the number with an SI prefix (k, M, G, +…). Their rendering can be tuned with fmt-style modifiers, e.g. +*"{bandwidthDownBits:>}"* or *"{bandwidthDownBits:M}"*. Modifiers may be combined +in any order: + +*<*, *=*, *>*: Alignment/padding (left, column-align, right) — as before. + +*u* / *U*: Hide (*u*) or show (*U*) the unit suffix. The unit is shown by default +with an auto scale and hidden by default when a scale is forced (see below). + +*#*, *k*, *M*, *G*, *T*, *P*: Force a fixed scale instead of auto-selecting one +(*#* = base scale, no prefix). Forcing a scale hides the scale prefix and, by +default, the unit (re-enable it with *U*). E.g. *"{bandwidthDownBits:M}"* always +shows the value in megabits. + +*i*: Force integer display (no decimals). + +*b* / *B*: Force decimal base 1000 (*b*) or binary base 1024 with an *i* marker +(*B*), overriding the module default. + +A trailing number is a fixed width for the coefficient when a scale is forced; if +the value does not fit, it is shown as *#* characters (e.g. *"{bandwidthDownBits:=3#}"* +→ *###*). Without a forced scale the number is ignored (kept for config +compatibility). + # EXAMPLES ``` diff --git a/test/utils/format.cpp b/test/utils/format.cpp new file mode 100644 index 00000000..63dc4eaa --- /dev/null +++ b/test/utils/format.cpp @@ -0,0 +1,105 @@ +#include "util/format.hpp" + +#if __has_include() +#include +#else +#include +#endif + +#include + +// Helpers to build the two representative sample values used throughout the +// pow_format modifier tests. +static std::string fmtA(const char* spec) { + // 1536 bytes, binary (base 1024) -> auto "1.5kiB" + return fmt::format(fmt::runtime(std::string("{:") + spec + "}"), pow_format(1536, "B", true)); +} +static std::string fmtB(const char* spec) { + // 1500000 b/s, decimal (base 1000) -> auto "1.5Mb/s" + return fmt::format(fmt::runtime(std::string("{:") + spec + "}"), pow_format(1500000, "b/s")); +} + +TEST_CASE("pow_format default/auto rendering", "[format][pow_format]") { + REQUIRE(fmt::format("{}", pow_format(1536, "B", true)) == "1.5kiB"); + REQUIRE(fmt::format("{}", pow_format(1500000, "b/s")) == "1.5Mb/s"); + // Sub-scale values and unit-only rendering. + REQUIRE(fmt::format("{}", pow_format(500, "B")) == "500.0B"); +} + +TEST_CASE("pow_format hide/show unit (u/U)", "[format][pow_format]") { + REQUIRE(fmtA("u") == "1.5ki"); // unit hidden, scale prefix (ki) kept + REQUIRE(fmtA("U") == "1.5kiB"); // explicit show = auto default + REQUIRE(fmtB("u") == "1.5M"); + REQUIRE(fmtB("U") == "1.5Mb/s"); +} + +TEST_CASE("pow_format force integer (i)", "[format][pow_format]") { + REQUIRE(fmtA("i") == "2kiB"); // 1.5 -> 2 + REQUIRE(fmtA("iu") == "2ki"); + REQUIRE(fmtB("i") == "2Mb/s"); +} + +TEST_CASE("pow_format forced scale hides prefix and unit", "[format][pow_format]") { + // Base scale (#): no prefix; unit off by default, back on with U. + REQUIRE(fmtA("#") == "1536.0"); + REQUIRE(fmtA("#U") == "1536.0B"); + REQUIRE(fmtA("#i") == "1536"); + REQUIRE(fmtB("#") == "1500000.0"); + REQUIRE(fmtB("#U") == "1500000.0b/s"); + + // Force kilo: whole prefix (ki) suppressed, unit off by default. + REQUIRE(fmtA("k") == "1.5"); + REQUIRE(fmtA("kU") == "1.5B"); // unit shown, no 'i' (it lives on the prefix) + REQUIRE(fmtA("ki") == "2"); + REQUIRE(fmtB("k") == "1500.0"); + REQUIRE(fmtB("kU") == "1500.0b/s"); + REQUIRE(fmtB("ki") == "1500"); + + // Force a scale far above the value's magnitude -> underflow to 0, no bump. + REQUIRE(fmtA("M") == "0.0"); + REQUIRE(fmtA("MU") == "0.0B"); + REQUIRE(fmtB("G") == "0.0"); +} + +TEST_CASE("pow_format force base (b/B)", "[format][pow_format]") { + // Force decimal on a binary call-site value. + REQUIRE(fmtA("b") == "1.5kB"); // base 1000, prefix 'k', no 'i' + REQUIRE(fmtA("bu") == "1.5k"); + REQUIRE(fmtA("b#") == "1536.0"); + + // Force binary on a decimal call-site value. + REQUIRE(fmtB("B") == "1.4Mib/s"); // base 1024, prefix 'Mi' + REQUIRE(fmtB("Bu") == "1.4Mi"); + REQUIRE(fmtB("Bk") == "1464.8"); + REQUIRE(fmtB("BkU") == "1464.8b/s"); +} + +TEST_CASE("pow_format fixed width with # overflow", "[format][pow_format]") { + // Base scale, numeric field width 3: 1536.0 (6 chars) overflows -> ###. + REQUIRE(fmtA("=3#") == "###"); + REQUIRE(fmtA("=6#") == "1536.0"); + REQUIRE(fmtA("=3#U") == "###B"); + REQUIRE(fmtA("=6#U") == "1536.0B"); + REQUIRE(fmtB("=4k#") == "####"); + // Width digit position is flexible: {:=3#} and {:=#3} are equivalent. + REQUIRE(fmtA("=#3") == "###"); + // Force kilo, width 6, coefficient "1500" fits, shown with unit; the '=' + // column left-justifies the coefficient in its field. + REQUIRE(fmtB("=6kiU") == "1500 b/s"); +} + +TEST_CASE("pow_format modifiers compose with alignment", "[format][pow_format]") { + REQUIRE(fmtA(">u") == " 1.5ki"); // right-align, unit hidden (prefix kept) + REQUIRE(fmtB(">ki") == "1500"); // force kilo+int (unit off), no scale width + REQUIRE(fmtB("Bu") == "1.4Mi"); // force binary, unit hidden +} + +TEST_CASE("pow_format backward compatible alignment", "[format][pow_format]") { + // These specs predate the new modifiers and must render as before. + REQUIRE(fmt::format("{:>}", pow_format(1536, "B", true)) == " 1.5kiB"); + REQUIRE(fmt::format("{:<}", pow_format(1536, "B", true)) == "1.5kiB "); + REQUIRE(fmt::format("{:=}", pow_format(1536, "B", true)) == "1.5 kiB"); + // Width without a forced scale is still ignored (only used for compat). + REQUIRE(fmt::format("{:>9}", pow_format(1536, "B", true)) == " 1.5kiB"); + REQUIRE(fmt::format("{}", pow_format(1500000, "b/s")) == "1.5Mb/s"); +} diff --git a/test/utils/meson.build b/test/utils/meson.build index 0d9130de..fb4ad60e 100644 --- a/test/utils/meson.build +++ b/test/utils/meson.build @@ -13,6 +13,7 @@ test_src = files( '../../src/config.cpp', 'JsonParser.cpp', 'SafeSignal.cpp', + 'format.cpp', 'sleeper_thread.cpp', 'command.cpp', 'command_line_stream.cpp',