Pith - whittle
#include "ui/AnalyzeDialog.h"
#include <algorithm>
#include <QFrame>
#include <QGridLayout>
#include <QHash>
#include <QHBoxLayout>
#include <QLabel>
#include <QList>
#include <QLocale>
#include <QPair>
#include <QScrollArea>
#include <QVBoxLayout>
#include <QWidget>

namespace whittle {
namespace {

constexpr char kHeadStyle[]  = "color: #999999; font-size: 8pt;";
constexpr char kRuleStyle[]  = "color: #cccccc; background-color: #cccccc;";
constexpr char kEmptyStyle[] = "color: #999999;";

constexpr char kSheet[] =
    "QDialog { background: #F4F4F4; }"
    "QScrollArea#area { border: 1px solid #c1c1c1; border-radius: 4px;"
    " background: #FAFAFA; }"
    "QWidget#body { background: transparent; }"
    "QLabel { color: #222222; }";

constexpr int kHeadGap    = 4;
constexpr int kSectionGap = 12;
constexpr int kRuleGap    = 8;
constexpr int kRowSpacing = 3;
constexpr int kLabelGap   = 15;
constexpr int kBodyPad    = 10;
constexpr int kMaxExt     = 10;

enum Cat { Images, Video, Audio, Documents, Archives, Code, System, Other,
           CatCount };

struct TypeInfo { const char* ext; const char* name; Cat cat; };

constexpr TypeInfo kTypes[] = {
    {"jpg", "JPEG Image", Images},       {"jpeg", "JPEG Image", Images},
    {"png", "PNG Image", Images},        {"gif", "GIF Image", Images},
    {"bmp", "Bitmap Image", Images},     {"tif", "TIFF Image", Images},
    {"tiff", "TIFF Image", Images},      {"webp", "WebP Image", Images},
    {"heic", "HEIC Image", Images},      {"avif", "AVIF Image", Images},
    {"svg", "SVG Image", Images},        {"ico", "Icon", Images},
    {"psd", "Photoshop Image", Images},  {"xcf", "GIMP Image", Images},
    {"raw", "Raw Image", Images},        {"cr2", "Canon Raw", Images},
    {"nef", "Nikon Raw", Images},        {"dng", "DNG Raw", Images},

    {"mp4", "MPEG-4 Video", Video},      {"m4v", "MPEG-4 Video", Video},
    {"mkv", "Matroska Video", Video},    {"webm", "WebM Video", Video},
    {"avi", "AVI Video", Video},         {"mov", "QuickTime Video", Video},
    {"wmv", "Windows Media Video", Video}, {"flv", "Flash Video", Video},
    {"mpg", "MPEG Video", Video},        {"mpeg", "MPEG Video", Video},
    {"3gp", "3GP Video", Video},

    {"mp3", "MP3 Audio", Audio},         {"flac", "FLAC Audio", Audio},
    {"wav", "WAV Audio", Audio},         {"ogg", "Ogg Audio", Audio},
    {"opus", "Opus Audio", Audio},       {"m4a", "AAC Audio", Audio},
    {"aac", "AAC Audio", Audio},         {"wma", "Windows Media Audio", Audio},
    {"aiff", "AIFF Audio", Audio},       {"mid", "MIDI", Audio},

    {"pdf", "PDF Document", Documents},  {"txt", "Text", Documents},
    {"md", "Markdown", Documents},       {"rtf", "Rich Text", Documents},
    {"doc", "Word Document", Documents}, {"docx", "Word Document", Documents},
    {"odt", "OpenDocument Text", Documents},
    {"xls", "Excel Spreadsheet", Documents},
    {"xlsx", "Excel Spreadsheet", Documents},
    {"ods", "OpenDocument Sheet", Documents},
    {"csv", "CSV Data", Documents},      {"ppt", "PowerPoint", Documents},
    {"pptx", "PowerPoint", Documents},   {"odp", "OpenDocument Slides", Documents},
    {"epub", "EPUB Book", Documents},    {"html", "HTML Document", Documents},
    {"htm", "HTML Document", Documents},

    {"zip", "ZIP Archive", Archives},    {"7z", "7-Zip Archive", Archives},
    {"rar", "RAR Archive", Archives},    {"tar", "Tar Archive", Archives},
    {"gz", "Gzip Archive", Archives},    {"tgz", "Tar Gzip Archive", Archives},
    {"bz2", "Bzip2 Archive", Archives},  {"xz", "XZ Archive", Archives},
    {"zst", "Zstd Archive", Archives},   {"iso", "Disc Image", Archives},
    {"img", "Disk Image", Archives},     {"deb", "Debian Package", Archives},
    {"rpm", "RPM Package", Archives},    {"apk", "Android Package", Archives},
    {"appimage", "AppImage", Archives},

    {"c", "C Source", Code},             {"h", "C Header", Code},
    {"cpp", "C++ Source", Code},         {"cc", "C++ Source", Code},
    {"hpp", "C++ Header", Code},         {"py", "Python", Code},
    {"js", "JavaScript", Code},          {"ts", "TypeScript", Code},
    {"java", "Java Source", Code},       {"kt", "Kotlin Source", Code},
    {"rs", "Rust Source", Code},         {"go", "Go Source", Code},
    {"sh", "Shell Script", Code},        {"json", "JSON", Code},
    {"xml", "XML", Code},                {"yaml", "YAML", Code},
    {"yml", "YAML", Code},               {"toml", "TOML", Code},
    {"css", "CSS Stylesheet", Code},     {"qml", "QML", Code},
    {"gradle", "Gradle Script", Code},   {"cmake", "CMake Script", Code},
    {"pro", "Qt Project", Code},         {"ui", "Qt Designer Form", Code},
    {"qrc", "Qt Resource", Code},

    {"so", "Shared Library", System},    {"a", "Static Library", System},
    {"o", "Object File", System},        {"ko", "Kernel Module", System},
    {"exe", "Windows Executable", System}, {"dll", "Windows Library", System},
    {"msi", "Windows Installer", System}, {"bin", "Binary", System},
    {"dat", "Data", System},             {"db", "Database", System},
    {"sqlite", "SQLite Database", System}, {"conf", "Config", System},
    {"cfg", "Config", System},           {"ini", "Config", System},
    {"desktop", "Desktop Entry", System}, {"service", "Systemd Unit", System},
    {"log", "Log", System},              {"tmp", "Temporary", System},
    {"bak", "Backup", System},           {"lock", "Lock File", System},
    {"cache", "Cache", System},          {"sys", "System File", System},
    {"lnk", "Windows Shortcut", System},
};

using Tally = QList<QPair<QString, int>>;

const TypeInfo* lookup(const QString& ext) {
    static const QHash<QString, const TypeInfo*> map = [] {
        QHash<QString, const TypeInfo*> m;
        for (const TypeInfo& t : kTypes) m.insert(QLatin1String(t.ext), &t);
        return m;
    }();
    return map.value(ext, nullptr);
}

QString extOf(const QString& relPath) {
    const QString name = relPath.section('/', -1);
    const int dot = name.lastIndexOf('.');
    if (dot <= 0 || dot == name.size() - 1) return {};
    const QString ext = name.mid(dot + 1).toLower();
    if (ext.size() > kMaxExt) return {};
    for (QChar c : ext)
        if (!c.isLetterOrNumber() && c != '_' && c != '-') return {};
    return ext;
}

Cat catOf(const QString& ext) {
    const TypeInfo* t = ext.isEmpty() ? nullptr : lookup(ext);
    return t ? t->cat : Other;
}

QString typeLabel(const QString& ext) {
    if (ext.isEmpty()) return AnalyzeDialog::tr("No Extension");
    const TypeInfo* t = lookup(ext);
    const QString name = t ? QString::fromLatin1(t->name)
                           : ext.toUpper() + AnalyzeDialog::tr(" File");
    return QStringLiteral("%1 (%2)").arg(name, ext);
}

QHash<QString, int> countExt(const QStringList& files) {
    QHash<QString, int> n;
    for (const QString& f : files) ++n[extOf(f)];
    return n;
}

QFrame* divider() {
    auto* hr = new QFrame;
    hr->setFrameShape(QFrame::HLine);
    hr->setFrameShadow(QFrame::Plain);
    hr->setStyleSheet(QLatin1String(kRuleStyle));
    hr->setFixedHeight(1);
    hr->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
    return hr;
}

QWidget* sectionHeader(const QString& title) {
    auto* w = new QWidget;
    auto* h = new QHBoxLayout(w);
    h->setContentsMargins(0, 0, 0, 0);
    h->setSpacing(8);

    auto* label = new QLabel(title);
    label->setStyleSheet(QLatin1String(kHeadStyle));
    label->setSizePolicy(QSizePolicy::Maximum, QSizePolicy::Fixed);
    h->addWidget(label);

    h->addWidget(divider(), 1);
    return w;
}

void addSection(QVBoxLayout* v, const QString& title, Tally rows) {
    std::sort(rows.begin(), rows.end(), [](const auto& a, const auto& b) {
        return a.second != b.second ? a.second > b.second : a.first < b.first;
    });
    if (!title.isEmpty()) {
        if (v->count() > 0) v->addSpacing(kSectionGap);
        v->addWidget(sectionHeader(title));
        v->addSpacing(kHeadGap);
    } else if (v->count() > 0) {
        v->addSpacing(kRuleGap);
        v->addWidget(divider());
        v->addSpacing(kRuleGap);
    }

    auto* grid = new QGridLayout;
    grid->setContentsMargins(0, 0, 0, 0);
    grid->setHorizontalSpacing(kLabelGap);
    grid->setVerticalSpacing(kRowSpacing);
    grid->setColumnStretch(0, 1);
    const QLocale loc;
    for (int i = 0; i < rows.size(); ++i) {
        auto* name  = new QLabel(typeLabel(rows[i].first));
        auto* count = new QLabel(loc.toString(rows[i].second));
        count->setAlignment(Qt::AlignRight | Qt::AlignVCenter);
        grid->addWidget(name,  i, 0);
        grid->addWidget(count, i, 1);
    }
    v->addLayout(grid);
}

Tally toTally(const QHash<QString, int>& counts) {
    Tally t;
    for (auto it = counts.constBegin(); it != counts.constEnd(); ++it)
        t.append({it.key(), it.value()});
    return t;
}

}

AnalyzeDialog::AnalyzeDialog(const QStringList& files,
                             const QStringList& deleted, bool merged,
                             QWidget* parent)
    : QDialog(parent) {
    setWindowTitle(merged ? tr("Analyze: Merge") : tr("Analyze: Scan"));
    setModal(true);
    resize(360, 460);
    setStyleSheet(QLatin1String(kSheet));

    auto* body = new QWidget;
    body->setObjectName(QStringLiteral("body"));
    auto* bv = new QVBoxLayout(body);
    bv->setContentsMargins(kBodyPad, kBodyPad, kBodyPad, kBodyPad);
    bv->setSpacing(0);

    Tally byCat[CatCount];
    const QHash<QString, int> counts = countExt(files);
    for (auto it = counts.constBegin(); it != counts.constEnd(); ++it)
        byCat[catOf(it.key())].append({it.key(), it.value()});
    for (int c = 0; c < CatCount; ++c)
        if (!byCat[c].isEmpty()) addSection(bv, QString(), byCat[c]);

    if (!deleted.isEmpty())
        addSection(bv, tr("Deleted"), toTally(countExt(deleted)));

    if (bv->count() == 0) {
        auto* none = new QLabel(tr("No files."));
        none->setStyleSheet(QLatin1String(kEmptyStyle));
        bv->addWidget(none);
    }
    bv->addStretch(1);

    auto* area = new QScrollArea;
    area->setObjectName(QStringLiteral("area"));
    area->setWidgetResizable(true);
    area->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
    area->setWidget(body);
    area->viewport()->setAutoFillBackground(false);

    auto* v = new QVBoxLayout(this);
    v->setContentsMargins(16, 14, 16, 14);
    v->setSpacing(0);
    v->addWidget(area);
}

}