diff --git a/src/Format.js b/src/Format.js index a359f60..3c80c86 100644 --- a/src/Format.js +++ b/src/Format.js @@ -65,6 +65,20 @@ function page(path, px, modified, paper, ink) { return "image://thumb/page/" + px + "/" + changed + "/" + hex(paper) + "/" + hex(ink) + "/" + encodeURIComponent(path) } +// A source text for a Text that reads StyledText: every run in its ink, every space and tab kept and every line end a break. +// Sample input: "int a;" with [[[0, 3, 5]]] and six inks, the fifth "#ff00ff", gives 'int a;'. +function colored(text, runs, inks) { + const kept = (part) => part.replace(/&/g, "&").replace(//g, ">").replace(/ /g, " ").replace(/\t/g, " ").replace(/\r/g, "") + return text.split("\n").map((line, number) => { + let out = "", at = 0 + for (const [start, length, ink] of runs[number] || []) { + out += kept(line.slice(at, start)) + '' + kept(line.slice(start, start + length)) + "" + at = start + length + } + return out + kept(line.slice(at)) + }).join("
") +} + // A key combination as people read it: "Ctrl + C", not "Ctrl+C". function keys(text) { return text.replace(/(\S)\+(?=\S)/g, "$1 + ") diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 76e7aea..3930801 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -83,11 +83,14 @@ Rectangle { Text { anchors.fill: parent anchors.margins: 14 - text: pane.info.text || "" + // A source file's lines in the theme's colors, which follow a change of theme. + readonly property bool colored: pane.info.colors !== undefined + text: colored ? Format.colored(pane.info.text, pane.info.colors, theme.codeInks) + : pane.info.text || "" color: theme.foreground font.pixelSize: 10 wrapMode: Text.WrapAnywhere - textFormat: Text.PlainText + textFormat: colored ? Text.StyledText : Text.PlainText elide: Text.ElideRight } } diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index eb7530e..cb35f10 100644 --- a/src/PreviewWindow.qml +++ b/src/PreviewWindow.qml @@ -2,6 +2,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Controls.Material import QtQuick.Layouts +import Folio import "Format.js" as Format // The large look at one file (Space in Folio, or `folio preview FILE`): @@ -305,6 +306,12 @@ ApplicationWindow { selectedTextColor: theme.accentForeground font.pixelSize: 13 } + // A source file in the theme's colors, which follow a change of theme. + CodeColors { + textDocument: body.textDocument + lines: previewer.colors + inks: theme.codeInks + } ScrollBar.vertical: FolioScrollBar { } } diff --git a/src/backend.cpp b/src/backend.cpp index 6224ee0..9e4b647 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -16,6 +16,9 @@ #include #include +// How long bat gets to color a text's opening lines, which the details wait for. +static constexpr int detailColorsMs = 500; + Backend::Backend(const QString &home, QObject *parent) : QObject(parent), m_home(home.isEmpty() ? QDir::homePath() : home) { m_noticeTimer.setSingleShot(true); @@ -393,7 +396,7 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { // Stepping through files faster than they're read queues readings; the // ones overtaken before they start are skipped rather than left to tie // up the threads the next folder is listed on. - watcher->setFuture(QtConcurrent::run([path, kind, inTrash, latest = m_describing, generation] { + watcher->setFuture(QtConcurrent::run([path, kind, mime, inTrash, latest = m_describing, generation] { QVariantMap read; if (*latest != generation) return read; @@ -416,7 +419,12 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { } else if (kind == "text") { // Enough of the top of the file to recognize it, never the whole // thing: a log can be gigabytes. - read.insert("text", Previewer::readText(path, 6000).section('\n', 0, 40)); + const QString text = Previewer::readText(path, 6000).section('\n', 0, 40); + read.insert("text", text); + // A source file's lines come with their colors, so that the pane draws them once; a plain text and Markdown have none to ask for. + if (CodeColors::isCode(mime) && !text.isEmpty()) + if (const QVariantList colors = CodeColors::runsOf(CodeColors::of(text, QFileInfo(path).fileName(), detailColorsMs), text); !colors.isEmpty()) + read.insert("colors", colors); } return read; })); diff --git a/src/codecolors.cpp b/src/codecolors.cpp new file mode 100644 index 0000000..be9b070 --- /dev/null +++ b/src/codecolors.cpp @@ -0,0 +1,182 @@ +#include "codecolors.h" + +#include +#include +#include +#include + +namespace { + +// How often a run of bat is looked at, and the most it may print for each byte it was given: a color around every character is ten. +constexpr int lookEveryMs = 25, mostPrintedPerByte = 16; + +// The ink an SGR number sets, or -1 for one that isn't a foreground: 31 to 36 and their bright 91 to 96 are the six, and 0, 39, black and white are the text's own. +int inkOf(int sgr) { + if (sgr == 0 || sgr == 39 || sgr == 30 || sgr == 37 || sgr == 90 || sgr == 97) + return 0; + if (sgr >= 31 && sgr <= 36) + return sgr - 30; + if (sgr >= 91 && sgr <= 96) + return sgr - 90; + return -1; +} + +} // namespace + +CodeColors::CodeColors(QObject *parent) : QSyntaxHighlighter(parent) {} + +// Sample input: "\x1b[35mint\x1b[0m \x1b[34mmain\x1b[0m() {}\n" is one line, "int main() {}", with int in ink 5 and main in ink 4. +CodeColors::Lines CodeColors::parse(const QString &printed) { + Lines lines; + Line line; + int ink = 0; + int from = 0; + const auto close = [&] { + if (ink > 0 && line.text.size() > from) + line.runs.append({from, int(line.text.size()) - from, ink}); + from = line.text.size(); + }; + for (qsizetype at = 0; at < printed.size(); ++at) { + const QChar ch = printed.at(at); + if (ch == u'\x1b' && at + 1 < printed.size() && printed.at(at + 1) == u'[') { + // A control sequence ends at its first letter; only "m", the colors, is read. + qsizetype end = at + 2; + while (end < printed.size() && !printed.at(end).isLetter()) + ++end; + if (end < printed.size() && printed.at(end) == u'm') { + const auto numbers = QStringView(printed).mid(at + 2, end - at - 2).split(u';'); + for (const QStringView number : numbers) { + const int sgr = number.isEmpty() ? 0 : number.toInt(); + // 38 and 48 are colors by number, with numbers of their own after them: none of the six. + if (sgr == 38 || sgr == 48) + break; + if (const int next = inkOf(sgr); next >= 0 && next != ink) { + close(); + ink = next; + } + } + } + at = end; + } else if (ch == u'\n') { + close(); + lines.append(line); + line = {}; + from = 0; + } else { + line.text.append(ch); + } + } + close(); + if (!line.text.isEmpty()) + lines.append(line); + return lines; +} + +CodeColors::Lines CodeColors::of(const QString &text, const QString &name, int limitMs, const std::function &wanted) { + QElapsedTimer run; + run.start(); + QProcess bat; + bat.setStandardErrorFile(QProcess::nullDevice()); + // The user's bat settings are left out: a pager, line numbers or a theme of their own would not be the text. + bat.start("bat", {"--no-config", "--no-custom-assets", "--theme=ansi", "--color=always", "--style=plain", "--paging=never", + "--wrap=never", "--tabs=0", "--file-name=" + name}); + if (!bat.waitForStarted(limitMs)) + return {}; + const QByteArray sent = text.toUtf8(); + bat.write(sent); + bat.closeWriteChannel(); + // More than colors take, whether it is printed without end or all at once by a program that then ends. + const auto tooMuch = [&] { return bat.bytesAvailable() > sent.size() * mostPrintedPerByte; }; + // The start and the run share the limit, and neither colors nobody will draw nor a program that prints without end are waited for. + while (!bat.waitForFinished(lookEveryMs) && bat.state() != QProcess::NotRunning) { + if (run.hasExpired(limitMs) || tooMuch() || (wanted && !wanted())) { + bat.kill(); + bat.waitForFinished(); + return {}; + } + } + if (bat.exitStatus() != QProcess::NormalExit || bat.exitCode() != 0 || tooMuch()) + return {}; + return parse(QString::fromUtf8(bat.readAllStandardOutput())); +} + +QVariantList CodeColors::runsOf(const Lines &lines, const QString &text) { + QStringList texts; + QVariantList all; + bool colored = false; + for (const Line &line : lines) { + texts << line.text; + QVariantList runs; + for (const Run &run : line.runs) + runs << QVariant(QVariantList{run.start, run.length, run.ink}); + colored = colored || !runs.isEmpty(); + all << QVariant(runs); + } + // bat ends a last line that had no end, and nothing else may differ. + const QString read = texts.join(u'\n'); + return colored && (read == text || read + u'\n' == text) ? all : QVariantList(); +} + +void CodeColors::setTextDocument(QQuickTextDocument *textDocument) { + if (textDocument == m_textDocument) + return; + m_textDocument = textDocument; + setDocument(textDocument ? textDocument->textDocument() : nullptr); + emit textDocumentChanged(); +} + +void CodeColors::setLines(const QVariant &lines) { + const Lines next = lines.value(); + if (next == m_lines) + return; + m_lines = next; + repaint(); + emit linesChanged(); +} + +void CodeColors::setInks(const QVariantList &inks) { + if (inks == m_inks) + return; + m_inks = inks; + repaint(); + emit inksChanged(); +} + +QList CodeColors::formatsOf(int number, const QString &text) const { + QList formats; + if (number >= m_lines.size()) + return formats; + const Line &line = m_lines.at(number); + // Colored only where the document's line is the line bat saw: a text that has moved on, or breaks its lines elsewhere, stays plain. + if (line.text != text && !(line.text.endsWith(u'\r') && QStringView(line.text).chopped(1) == text)) + return formats; + for (const Run &run : line.runs) { + // A run that took in the line's own end stops where the document's line does. + const int length = qMin(run.length, int(text.size()) - run.start); + if (run.ink > m_inks.size() || length <= 0) + continue; + QTextLayout::FormatRange range; + range.start = run.start; + range.length = length; + range.format.setForeground(m_inks.at(run.ink - 1).value()); + formats.append(range); + } + return formats; +} + +// A text that is set: each of its lines as the document reads it. +void CodeColors::highlightBlock(const QString &text) { + const auto formats = formatsOf(currentBlock().blockNumber(), text); + for (const QTextLayout::FormatRange &range : formats) + setFormat(range.start, range.length, range.format); +} + +// Colors that come for a text already shown: every line's at once and the document told once, where rehighlight() tells it line by line, half a second for a long text. +void CodeColors::repaint() { + QTextDocument *shown = document(); + if (!shown) + return; + for (QTextBlock block = shown->begin(); block.isValid(); block = block.next()) + block.layout()->setFormats(formatsOf(block.blockNumber(), block.text())); + shown->markContentsDirty(0, shown->characterCount()); +} diff --git a/src/codecolors.h b/src/codecolors.h new file mode 100644 index 0000000..f5545aa --- /dev/null +++ b/src/codecolors.h @@ -0,0 +1,70 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +// Draws a source text in color, as bat reads it: Omarchy ships bat, and its "ansi" theme names six terminal colors, drawn here in the theme's own. +class CodeColors : public QSyntaxHighlighter { + Q_OBJECT + Q_PROPERTY(QQuickTextDocument *textDocument READ textDocument WRITE setTextDocument NOTIFY textDocumentChanged) + Q_PROPERTY(QVariant lines READ lines WRITE setLines NOTIFY linesChanged) + Q_PROPERTY(QVariantList inks READ inks WRITE setInks NOTIFY inksChanged) + +public: + // A stretch of a line in one ink: 1 to 6 are red, green, yellow, blue, magenta and cyan. + struct Run { + int start = 0; + int length = 0; + int ink = 0; + bool operator==(const Run &other) const { return start == other.start && length == other.length && ink == other.ink; } + }; + // A line as bat printed it, without its escapes, and the runs that aren't the foreground. + struct Line { + QString text; + QVector runs; + bool operator==(const Line &other) const { return text == other.text && runs == other.runs; } + }; + using Lines = QVector; + + explicit CodeColors(QObject *parent = nullptr); + + // Whether bat is asked about a text of this type: not about a plain text, nor about Markdown, which is shown as it always was. + static bool isCode(const QString &mime) { return mime != "text/plain" && mime != "text/markdown"; } + // What bat printed, line by line. + static Lines parse(const QString &printed); + // The colors of a text from a file of this name; none without bat, when it fails, takes longer than the limit or prints more than colors take, or once they aren't wanted. + static Lines of(const QString &text, const QString &name, int limitMs, const std::function &wanted = {}); + // The runs of each line as numbers, start, length and ink, for QML; none when nothing is colored or the lines aren't this text's. + static QVariantList runsOf(const Lines &lines, const QString &text); + + QQuickTextDocument *textDocument() const { return m_textDocument; } + void setTextDocument(QQuickTextDocument *textDocument); + QVariant lines() const { return QVariant::fromValue(m_lines); } + void setLines(const QVariant &lines); + QVariantList inks() const { return m_inks; } + void setInks(const QVariantList &inks); + +signals: + void textDocumentChanged(); + void linesChanged(); + void inksChanged(); + +protected: + void highlightBlock(const QString &text) override; + +private: + // The colors of one of the document's lines, none when it isn't the line bat read. + QList formatsOf(int number, const QString &text) const; + void repaint(); + + QQuickTextDocument *m_textDocument = nullptr; + Lines m_lines; + QVariantList m_inks; +}; + +Q_DECLARE_METATYPE(CodeColors::Lines) diff --git a/src/main.cpp b/src/main.cpp index 9228dca..b35c4a7 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -107,6 +107,7 @@ static int preview(QGuiApplication &app) { engine.rootContext()->setContextProperty("translator", &translator); engine.rootContext()->setContextProperty("theme", &theme); engine.rootContext()->setContextProperty("previewer", &previewer); + qmlRegisterType("Folio", 1, 0, "CodeColors"); engine.load(QUrl("qrc:/PreviewWindow.qml")); if (engine.rootObjects().isEmpty()) return 1; diff --git a/src/previewer.cpp b/src/previewer.cpp index 7ac8bf0..43d0463 100644 --- a/src/previewer.cpp +++ b/src/previewer.cpp @@ -11,8 +11,44 @@ #include #include +namespace { + +// What reading an item in the background brings back. +struct Reading { + FolderModel::Entry entry; + QString text; + CodeColors::Lines colors; + // Whether bat is to color the whole text once it is shown. + bool rest = false; +}; + +// How long bat gets for the first lines, which the text waits for, and for a whole text, which it does not. +constexpr int firstColorsMs = 500, allColorsMs = 5000; + +// The opening of a text that is colored before it is shown: whole lines, no more of them than firstColored nor more bytes together, as bat is sent them, than firstColoredSize, so none when the first is. +QString openingOf(const QString &text) { + qsizetype kept = 0, from = 0, bytes = 0; + for (int line = 0; line < Previewer::firstColored && from <= text.size(); ++line) { + const qsizetype end = text.indexOf(u'\n', from), stop = end < 0 ? text.size() : end; + // A line after the first is sent with the line end ahead of it. + bytes += QStringView(text).mid(from, stop - from).toUtf8().size() + (line ? 1 : 0); + if (bytes > Previewer::firstColoredSize) + break; + kept = stop; + from = stop + 1; + } + return text.left(kept); +} + +} // namespace + Previewer::Previewer(QObject *parent) : QObject(parent) {} +// A bat still on a whole text is stopped with the window, where quitting would wait for it. +Previewer::~Previewer() { + ++*m_reading; +} + QString Previewer::viewerFor(const QString &mime, bool isDir) { if (isDir) return "folder"; @@ -208,6 +244,7 @@ void Previewer::setIndex(int index, bool report) { const FolderModel::Entry e = m_entries.at(index); m_viewer = viewerFor(e.mime, e.isDir); m_text.clear(); + m_colors.clear(); // A folder's count and first pictures, and a text's opening part, are // read off the UI thread: a big folder, or any file on a slow mount, @@ -216,32 +253,46 @@ void Previewer::setIndex(int index, bool report) { const bool count = e.isDir && e.itemCount < 0; const bool text = m_viewer == "text" || m_viewer == "markdown"; if (count || text) { - auto *watcher = new QFutureWatcher>(this); + auto *watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, text] { watcher->deleteLater(); if (generation != *m_reading) return; // Stepped on to another item while this was read. - const auto [entry, read] = watcher->result(); - m_entries[m_index] = entry; - m_text = read; + const Reading reading = watcher->result(); + m_entries[m_index] = reading.entry; + m_text = reading.text; + m_colors = reading.colors; // A ".txt" that turns out to be binary is shown as a plain file. - if (text && m_text.isEmpty() && entry.size > 0) + if (text && m_text.isEmpty() && reading.entry.size > 0) m_viewer = "other"; + emit colored(); emit changed(); + // The lines past the first ones are colored once bat has read the whole text, which a long one would wait too long for. + if (reading.rest) + colorTheRest(generation); }); const bool showHidden = m_showHidden; const bool markdown = m_viewer == "markdown"; - watcher->setFuture(QtConcurrent::run([entry = e, showHidden, count, text, markdown, + // A plain text has nothing to color, and Markdown is laid out as what it says. + const bool code = m_viewer == "text" && CodeColors::isCode(e.mime); + watcher->setFuture(QtConcurrent::run([entry = e, showHidden, count, text, markdown, code, latest = m_reading, generation]() mutable { if (*latest != generation) - return qMakePair(entry, QString()); + return Reading{entry, {}, {}, false}; if (count) FolderModel::summarize(entry, showHidden); const QString read = text ? readText(entry.path) : QString(); - return qMakePair(entry, markdown ? markdownOpening(read) : read); + if (!code || read.isEmpty()) + return Reading{entry, markdown ? markdownOpening(read) : read, {}, false}; + // An opening that holds no line isn't waited for, and a bat that failed on an opening isn't asked for the rest. + const QString opening = openingOf(read); + const CodeColors::Lines colors = opening.isEmpty() ? CodeColors::Lines() : CodeColors::of(opening, entry.name, firstColorsMs); + return Reading{entry, read, colors, (opening.isEmpty() || !colors.isEmpty()) && read.size() > opening.size() + 1}; })); } emit changed(); + // Said after the text has gone: colors cleared over a long text still shown would lay it out once more. + emit colored(); emit listed(); if (report && m_reporting) { @@ -250,6 +301,24 @@ void Previewer::setIndex(int index, bool report) { } } +void Previewer::colorTheRest(quint64 generation) { + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { + watcher->deleteLater(); + const CodeColors::Lines all = watcher->result(); + // Stepped on to another item, or bat gave up on the whole text: the first lines keep their colors. + if (generation != *m_reading || all.isEmpty()) + return; + m_colors = all; + emit colored(); + }); + watcher->setFuture(QtConcurrent::run([text = m_text, name = m_entries.at(m_index).name, latest = m_reading, generation] { + // Stopped once another item is shown: a long text's colors can take a while, and nobody would see them. + const auto wanted = [&latest, generation] { return *latest == generation; }; + return wanted() ? CodeColors::of(text, name, allColorsMs, wanted) : CodeColors::Lines(); + })); +} + void Previewer::connectToFolio() { m_reporting = true; m_stdin = new QSocketNotifier(STDIN_FILENO, QSocketNotifier::Read, this); diff --git a/src/previewer.h b/src/previewer.h index 9aee60e..e1a4170 100644 --- a/src/previewer.h +++ b/src/previewer.h @@ -5,6 +5,7 @@ #include #include +#include "codecolors.h" #include "foldermodel.h" // The state behind `folio preview`: the file being looked at, its siblings @@ -26,10 +27,13 @@ class Previewer : public QObject { Q_PROPERTY(QString viewer READ viewer NOTIFY changed) Q_PROPERTY(QString url READ url NOTIFY changed) Q_PROPERTY(QString text READ text NOTIFY changed) + // A source text's colors, with a signal of their own: a long text's later lines get theirs after it is shown, and nothing else changes then. + Q_PROPERTY(QVariant colors READ colors NOTIFY colored) Q_PROPERTY(QString type READ type NOTIFY changed) public: explicit Previewer(QObject *parent = nullptr); + ~Previewer() override; // Shows the file right away, then lists its folder in the background so // the arrow keys can step to its neighbours. @@ -48,6 +52,7 @@ class Previewer : public QObject { QString viewer() const { return m_viewer; } QString url() const; QString text() const { return m_text; } + QVariant colors() const { return QVariant::fromValue(m_colors); } QString type() const; Q_INVOKABLE void next(); @@ -65,18 +70,23 @@ class Previewer : public QObject { // `cells` cells: laid out, each costs the UI thread about as much as a // whole line of prose, and a long table froze the preview for seconds. static QString markdownOpening(const QString &text, int cells = 600); + // The lines colored before a text is shown and the most characters they may hold: more than a window shows at first, and bat is through them in a few hundredths of a second. + static constexpr int firstColored = 400, firstColoredSize = 16 * 1024; signals: void changed(); void listed(); + void colored(); private: void setIndex(int index, bool report); + void colorTheRest(quint64 generation); QVector m_entries; int m_index = -1; QString m_viewer; QString m_text; + CodeColors::Lines m_colors; bool m_showHidden = false; QString m_sort = "name"; quint64 m_generation = 0; diff --git a/src/theme.cpp b/src/theme.cpp index c3f4401..07de54d 100644 --- a/src/theme.cpp +++ b/src/theme.cpp @@ -24,6 +24,7 @@ const QHash &fallbackColors() { {"foreground", QColor("#c8c8d0")}, {"bright_foreground", QColor("#ececf1")}, {"red", QColor("#f7768e")}, + {"green", QColor("#9ece6a")}, {"yellow", QColor("#e0af68")}, {"blue", QColor("#7aa2f7")}, {"magenta", QColor("#bb9af7")}, @@ -38,6 +39,18 @@ double luminance(const QColor &c) { return .2126 * linear(c.redF()) + .7152 * linear(c.greenF()) + .0722 * linear(c.blueF()); } +// How well one color reads on another, from 1 for none to 21 for black on white (WCAG). +double contrast(const QColor &a, const QColor &b) { + const double one = luminance(a), other = luminance(b); + return (qMax(one, other) + .05) / (qMin(one, other) + .05); +} + +// What code needs to be read at its small size (WCAG's 4.5:1 for text), and the halvings that find it to a 4096th of the way. +constexpr double codeContrast = 4.5; +constexpr int codeInkHalvings = 12; +// The luminance of a ground above which black reads on it better than white, as accentForeground() has it. +constexpr double lightGround = .179; + QColor mix(const QColor &a, const QColor &b, double t) { return QColor::fromRgbF(a.redF() * (1 - t) + b.redF() * t, a.greenF() * (1 - t) + b.greenF() * t, a.blueF() * (1 - t) + b.blueF() * t); @@ -152,6 +165,27 @@ QColor Theme::lighterBackground() const { return mix(bg, foreground(), 0.08); } +// Some themes' colors are too faint for code on their own background, a yellow on a light one: such a color is moved toward black or white, whichever the background isn't, as far as it takes to read and no further. +QVariantList Theme::codeInks() const { + QVariantList inks; + for (const QColor &own : {red(), green(), yellow(), blue(), magenta(), cyan()}) { + // The preview draws code on the dark background and the details on the background: the one this color shows less on decides. + const QColor ground = contrast(own, background()) < contrast(own, darkBackground()) ? background() : darkBackground(); + if (contrast(own, ground) >= codeContrast) { + inks << own; + continue; + } + const QColor toward = luminance(ground) > lightGround ? QColor("black") : QColor("white"); + double low = 0, high = 1; + for (int halving = 0; halving < codeInkHalvings; ++halving) { + const double middle = (low + high) / 2; + (contrast(mix(own, toward, middle), ground) < codeContrast ? low : high) = middle; + } + inks << mix(own, toward, high); + } + return inks; +} + // Black or white, whichever reads on the accent (WCAG relative luminance). QColor Theme::accentForeground() const { return luminance(accent()) > .179 ? QColor("black") : QColor("white"); diff --git a/src/theme.h b/src/theme.h index 221b9de..79d6c9c 100644 --- a/src/theme.h +++ b/src/theme.h @@ -26,6 +26,7 @@ class Theme : public QObject { Q_PROPERTY(QColor magenta READ magenta NOTIFY changed) Q_PROPERTY(QColor cyan READ cyan NOTIFY changed) Q_PROPERTY(QColor orange READ orange NOTIFY changed) + Q_PROPERTY(QVariantList codeInks READ codeInks NOTIFY changed) Q_PROPERTY(bool light READ light NOTIFY changed) Q_PROPERTY(int radius READ radius NOTIFY changed) @@ -42,10 +43,13 @@ class Theme : public QObject { QColor brightForeground() const { return color("bright_foreground"); } QColor secondaryForeground() const; QColor red() const { return color("red"); } + QColor green() const { return color("green"); } QColor yellow() const { return color("yellow"); } QColor blue() const { return color("blue"); } QColor magenta() const { return color("magenta"); } QColor cyan() const { return color("cyan"); } + // The six colors code is drawn in, as bat numbers them: red, green, yellow, blue, magenta and cyan, each as near the theme's own as reads on its backgrounds. + QVariantList codeInks() const; QColor orange() const { return color("orange"); } bool light() const { return m_light; } int radius() const { return m_radius; } diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d0..6065e90 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1,3 +1,4 @@ +#include #include #include #include @@ -6,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -36,6 +38,7 @@ #include #include "backend.h" +#include "codecolors.h" #include "disks.h" #include "fileops.h" #include "foldermodel.h" @@ -3305,6 +3308,182 @@ private slots: QVERIFY(!backend.details().contains("width")); } + void detailsColorTheOpeningLinesOfCode() + { + // The details show a source file's opening lines in the theme's colors: read with the text, and laid out as the plain text is. + QTemporaryDir home; + const QString ran = home.filePath("ran"); + // Stands in for bat: the first word of every line in magenta, noting that it ran. + const QString bin = binWith(home.filePath("bin"), {{"bat", "#!/bin/sh\n: > '" + QFile::encodeName(ran) + "'\n" + "while IFS= read -r line || [ -n \"$line\" ]; do\n" + " first=${line%% *}\n" + " printf '\\033[35m%s\\033[0m%s\\n' \"$first\" \"${line#\"$first\"}\"\n" + "done\n"}}); + const QString stuck = binWith(home.filePath("stuck"), {{"bat", "#!/bin/sh\nexec tail -f /dev/null\n"}}); + // A bat that never answers is given up on well inside the second of these, and the first is all a slow machine is waited for. + const int longMs = 15000, soonMs = 2000; + QDir(home.path()).mkdir("none"); + QDir(home.path()).mkdir("src"); + const QString src = home.filePath("src"); + // Two spaces, a tab, what HTML would read as a tag, an empty line and a line that wraps: all of which must stay as they are. + const QByteArray text = "int main() {\n\treturn 0; // a & c\n}\n\nlast line that is long enough to wrap around the narrow pane twice over, surely\n"; + write(src + "/press.c", text); + write(src + "/notes.txt", text); + // Tabs at the start of a line and inside one, in a colored word and outside. + const QByteArray tabs = "\tx\n \ty\nz\tw q\n"; + write(src + "/tabs.c", tabs); + write(src + "/tabs.txt", tabs); + // Windows line ends, after a plain word and after a colored one. + const QByteArray ends = "ab cd\r\nef\r\n"; + write(src + "/ends.c", ends); + write(src + "/ends.txt", ends); + const QVariantMap code{{"path", src + "/press.c"}, {"kind", "text"}, {"mime", "text/x-csrc"}}; + const QVariantMap notes{{"path", src + "/notes.txt"}, {"kind", "text"}, {"mime", "text/plain"}}; + write(src + "/readme.md", "# int main\n"); + const QVariantMap readme{{"path", src + "/readme.md"}, {"kind", "text"}, {"mime", "text/markdown"}}; + + Backend backend(home.path()); + // Whether the colors were there each time a text arrived. + QVector arrived; + connect(&backend, &Backend::detailsChanged, &backend, [&] { + if (backend.details().contains("text")) + arrived << backend.details().contains("colors"); + }); + { + const auto path = pathAt(bin); + backend.describe(code); + QTRY_VERIFY(backend.details().contains("text")); + // The text and its colors in one change, not one after the other. + QCOMPARE(arrived, QVector{true}); + QCOMPARE(backend.details().value("text").toString(), QString::fromUtf8(text)); + const QVariantList colors = backend.details().value("colors").toList(); + QCOMPARE(colors.size(), 5); + QCOMPARE(colors.at(0).toList(), (QVariantList{QVariantList{0, 3, 5}})); + QCOMPARE(colors.at(1).toList(), (QVariantList{QVariantList{0, 7, 5}})); + QVERIFY(colors.at(3).toList().isEmpty()); + } + { + // The same file read again without bat: its lines are shown as they always were, and the colors it had are gone. + const auto path = pathAt(home.filePath("none")); + backend.describe(code); + QTRY_COMPARE(arrived, (QVector{true, false})); + QVERIFY(!backend.details().contains("colors")); + QCOMPARE(backend.details().value("text").toString(), QString::fromUtf8(text)); + } + for (const QVariantMap &plain : {notes, readme}) { + // A plain text has no colors to ask bat for, and Markdown's lines stay as they were: neither waits for it. + const auto path = pathAt(bin); + QFile::remove(ran); + const int before = arrived.size(); + backend.describe(plain); + QTRY_COMPARE(arrived.size(), before + 1); + QThreadPool::globalInstance()->waitForDone(); + QVERIFY(!backend.details().contains("colors")); + QVERIFY(!QFile::exists(ran)); + } + { + // A bat that never answers holds the lines back half a second, not the seconds a whole text is given. + const auto path = pathAt(stuck); + QElapsedTimer waited; + waited.start(); + backend.describe(code); + QTRY_COMPARE_WITH_TIMEOUT(arrived.size(), 5, longMs); + QVERIFY(waited.elapsed() < soonMs); + QVERIFY(!arrived.last()); + } + // Colors for another text than the one read are no colors. + const CodeColors::Lines lines{{"int a;", {{0, 3, 5}}}, {"b", {}}}; + QCOMPARE(CodeColors::runsOf(lines, "int a;\nb\n").size(), 2); + QCOMPARE(CodeColors::runsOf(lines, "int a;\nb").size(), 2); + QVERIFY(CodeColors::runsOf(lines, "int a;\nc\n").isEmpty()); + QVERIFY(CodeColors::runsOf(lines, "int a;\nb\nc\n").isEmpty()); + QVERIFY(CodeColors::runsOf({{"int a;", {}}}, "int a;").isEmpty()); + + // In the pane: the same lines, the same height and the same wraps as the plain text, with the colored words in the theme's inks. + backend.navigate(src); + QTRY_COMPARE(backend.folder()->rowCount(), 7); + Theme theme(home.filePath("theme")); + FileOps fileOps; + Places places(home.path()); + QQmlEngine engine; + engine.rootContext()->setContextProperty("backend", &backend); + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("fileOps", &fileOps); + engine.rootContext()->setContextProperty("places", &places); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + QQmlComponent component(&engine); + component.setData("import QtQuick\n" + "Window { width: 300; height: 700; visible: true\n" + " QtObject { id: files; property var current: ({}); readonly property bool inTrash: false\n" + " readonly property var paneBackend: backend; readonly property var paneFileOps: fileOps }\n" + " PreviewPane { id: pane; anchors.fill: parent; folderPane: files }\n" + " function show(name) { files.current = backend.folder.get(backend.folder.indexOfPath(backend.path + '/' + name)) }\n" + " function find(item, wanted) {\n" + " if (wanted(item)) return item\n" + " for (const child of item.children) { const found = find(child, wanted); if (found) return found }\n" + " return null }\n" + " function opening() { return find(pane, item => item instanceof Text && item.wrapMode === Text.WrapAnywhere) }\n" + " function styled() { return opening().textFormat === Text.StyledText }\n" + " function shown() { return opening().text }\n" + " function laidOut() { const text = opening(); return [text.lineCount, text.implicitWidth, text.implicitHeight, text.contentHeight].join(' ') }\n" + "}\n", + QUrl::fromLocalFile(sourceFolder() + "/DetailsCodeTest.qml")); + const auto path = pathAt(bin); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + QVERIFY(QTest::qWaitForWindowExposed(window)); + const auto asked = [window](const char *method) { + QVariant result; + QMetaObject::invokeMethod(window, method, Q_RETURN_ARG(QVariant, result)); + return result; + }; + QMetaObject::invokeMethod(window, "show", Q_ARG(QVariant, "notes.txt")); + QTRY_COMPARE(asked("shown").toString(), QString::fromUtf8(text)); + QVERIFY(!asked("styled").toBool()); + const QString plain = asked("laidOut").toString(); + QVERIFY(plain.startsWith("9 ")); + + QMetaObject::invokeMethod(window, "show", Q_ARG(QVariant, "press.c")); + QTRY_VERIFY(asked("styled").toBool()); + const QString ink = theme.codeInks().at(4).value().name(); + QCOMPARE(asked("shown").toString(), + "int  main() {
" + " return 0; // a <b> & c
" + "}

" + "last line that is long enough to wrap around the narrow pane twice over, surely
"); + QCOMPARE(asked("laidOut").toString(), plain); + + // A tab is as wide as in the plain text, where a Text that reads StyledText would draw it as one space or none. + QMetaObject::invokeMethod(window, "show", Q_ARG(QVariant, "tabs.txt")); + QTRY_COMPARE(asked("shown").toString(), QString::fromUtf8(tabs)); + const QString plainTabs = asked("laidOut").toString(); + QMetaObject::invokeMethod(window, "show", Q_ARG(QVariant, "tabs.c")); + QTRY_COMPARE(asked("shown").toString(), " x
  y
z w q
"); + QCOMPARE(asked("laidOut").toString(), plainTabs); + + // A Windows line end is one break, as in the plain text: its \r is left out, where a Text that reads StyledText would draw it as a space. + QMetaObject::invokeMethod(window, "show", Q_ARG(QVariant, "ends.txt")); + QTRY_COMPARE(asked("shown").toString(), QString::fromUtf8(ends)); + const QString plainEnds = asked("laidOut").toString(); + QMetaObject::invokeMethod(window, "show", Q_ARG(QVariant, "ends.c")); + QTRY_COMPARE(asked("shown").toString(), "ab cd
ef
"); + QCOMPARE(asked("laidOut").toString(), plainEnds); + QMetaObject::invokeMethod(window, "show", Q_ARG(QVariant, "press.c")); + QTRY_VERIFY(asked("shown").toString().startsWith("int")); + + // Another theme is picked while the file is selected: its lines are redrawn in that theme's colors, laid out as before. + QDir(home.path()).mkpath("theme/theme"); + write(home.filePath("theme/theme/colors.toml"), "magenta = \"#ffaaff\"\n"); + QTRY_VERIFY(asked("shown").toString().startsWith("int")); + QCOMPARE(asked("laidOut").toString(), plain); + + // Back on a plain text the Text reads it plainly again: a "<" in it is a "<". + QMetaObject::invokeMethod(window, "show", Q_ARG(QVariant, "notes.txt")); + QTRY_VERIFY(!asked("styled").toBool()); + QTRY_COMPARE(asked("shown").toString(), QString::fromUtf8(text)); + } + void backendReadsTrashNotesOffTheUiThread() { // Finding a trashed item's note looks over every mounted drive, so @@ -5714,6 +5893,489 @@ private slots: QCOMPARE(previewer.text(), QString()); } + void codeColorsReadWhatBatPrints() + { + // bat's escapes become runs of one of six inks, line by line, and the text is what is left without them. + using Runs = QVector; + const CodeColors::Lines one = CodeColors::parse("\x1b[35mint\x1b[0m \x1b[34mmain\x1b[0m() {}\n"); + QCOMPARE(one.size(), 1); + QCOMPARE(one.at(0).text, QString("int main() {}")); + QCOMPARE(one.at(0).runs, (Runs{{0, 3, 5}, {4, 4, 4}})); + + // A color left on where a line ends goes on in the next; 39 and a bare m end it, and the bright six are the six. + const CodeColors::Lines two = CodeColors::parse("\x1b[32m/* a\nb */\x1b[39m c \x1b[93md\x1b[m e"); + QCOMPARE(two.size(), 2); + QCOMPARE(two.at(0).text, QString("/* a")); + QCOMPARE(two.at(0).runs, (Runs{{0, 4, 2}})); + QCOMPARE(two.at(1).text, QString("b */ c d e")); + QCOMPARE(two.at(1).runs, (Runs{{0, 4, 2}, {7, 1, 3}})); + + // Bold, a color by number and a cleared line set no ink and leave no text; a line's \r stays its last character. + const CodeColors::Lines three = CodeColors::parse("\x1b[1ma\x1b[38;5;31mb\x1b[2Kc\x1b[1;31md\x1b[0m\r\n\nplain\n"); + QCOMPARE(three.size(), 3); + QCOMPARE(three.at(0).text, QString("abcd\r")); + QCOMPARE(three.at(0).runs, (Runs{{3, 1, 1}})); + QCOMPARE(three.at(1).text, QString()); + QVERIFY(three.at(1).runs.isEmpty()); + QCOMPARE(three.at(2).text, QString("plain")); + QVERIFY(three.at(2).runs.isEmpty()); + // A sequence that isn't colors, an erase to the line's end here, is passed over whatever number it carries. + const CodeColors::Lines erased = CodeColors::parse("a\x1b[31Kb\n"); + QCOMPARE(erased.at(0).text, QString("ab")); + QVERIFY(erased.at(0).runs.isEmpty()); + // The ends of the six and of the bright six, the four that are the text's own, and a background by number, which is no ink. + const CodeColors::Lines ends = CodeColors::parse("\x1b[36ma\x1b[91mb\x1b[96mc\x1b[30md\x1b[31me\x1b[37mf\x1b[31mg\x1b[90mh\x1b[31mi\x1b[97mj\x1b[48;5;32mk"); + QCOMPARE(ends.at(0).text, QString("abcdefghijk")); + QCOMPARE(ends.at(0).runs, (Runs{{0, 1, 6}, {1, 1, 1}, {2, 1, 6}, {4, 1, 1}, {6, 1, 1}, {8, 1, 1}})); + QVERIFY(CodeColors::parse("").isEmpty()); + } + + void codeColorsPaintTheLinesTheyWereReadFrom() + { + // The colors are drawn over a text's own lines: a line that isn't the one bat read stays plain. + qmlRegisterType("Folio", 1, 0, "CodeColors"); + QQmlEngine engine; + QQmlComponent component(&engine); + component.setData("import QtQuick\nimport Folio\n" + "TextEdit { id: body; property alias colors: colors; text: 'int main() {}\\nreturn 0;'\n" + " CodeColors { id: colors; textDocument: body.textDocument } }\n", QUrl()); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *colors = root->property("colors").value(); + QVERIFY(colors); + const QColor red("#ff0000"), green("#00ff00"), yellow("#ffff00"), blue("#0000ff"), magenta("#ff00ff"), cyan("#00ffff"); + // The ink a character of a line is drawn in, or no color for the text's own. + const auto inkAt = [colors](int line, int column) { + const auto formats = colors->document()->findBlockByNumber(line).layout()->formats(); + for (const QTextLayout::FormatRange &range : formats) + if (range.start <= column && column < range.start + range.length) + return range.format.foreground().color(); + return QColor(); + }; + const CodeColors::Lines lines{{"int main() {}", {{0, 3, 5}, {4, 4, 4}, {11, 2, 6}}}, {"return 0;", {{0, 6, 5}, {7, 1, 3}}}}; + + // Colors without inks to draw them in draw nothing. + colors->setLines(QVariant::fromValue(lines)); + QCOMPARE(inkAt(0, 0), QColor()); + colors->setInks({red, green, yellow, blue, magenta, cyan}); + QCOMPARE(inkAt(0, 0), magenta); + QCOMPARE(inkAt(0, 2), magenta); + QCOMPARE(inkAt(0, 3), QColor()); + QCOMPARE(inkAt(0, 4), blue); + QCOMPARE(inkAt(0, 8), QColor()); + QCOMPARE(inkAt(0, 12), cyan); + QCOMPARE(inkAt(1, 0), magenta); + QCOMPARE(inkAt(1, 7), yellow); + + // Another theme's inks are drawn at once. + colors->setInks({red, green, cyan, blue, yellow, magenta}); + QCOMPARE(inkAt(0, 0), yellow); + QCOMPARE(inkAt(0, 12), magenta); + QCOMPARE(inkAt(1, 7), cyan); + + // The text moves on before its colors do: the line that changed is plain, the one that didn't keeps its colors. + root->setProperty("text", "int x;\nreturn 0;"); + QCOMPARE(inkAt(0, 0), QColor()); + QCOMPARE(inkAt(1, 0), yellow); + // More lines than were read, and fewer. + root->setProperty("text", "int main() {}\nreturn 0;\nreturn 0;"); + QCOMPARE(inkAt(0, 0), yellow); + QCOMPARE(inkAt(2, 0), QColor()); + root->setProperty("text", "int main() {}"); + QCOMPARE(inkAt(0, 4), blue); + + // A file with Windows line ends: bat keeps the \r in its line and the document doesn't. + root->setProperty("text", "int a;\r\nint b;\r\n"); + colors->setLines(QVariant::fromValue(CodeColors::Lines{{"int a;\r", {{0, 3, 5}}}, {"int b;\r", {{0, 3, 5}}}})); + QCOMPARE(inkAt(0, 0), yellow); + QCOMPARE(inkAt(1, 0), yellow); + // A run that took in the \r stops where the document's line does. + colors->setLines(QVariant::fromValue(CodeColors::Lines{{"int a;\r", {{0, 3, 5}, {6, 1, 5}}}, {"int b;\r", {{4, 3, 5}}}})); + QCOMPARE(colors->document()->findBlockByNumber(0).layout()->formats().size(), 1); + const auto ended = colors->document()->findBlockByNumber(1).layout()->formats(); + QCOMPARE(ended.size(), 1); + QCOMPARE(ended.at(0).start, 4); + QCOMPARE(ended.at(0).length, 2); + // Colors that come for a text already shown are told to its layout once, not line by line, which held the window half a second for a long text. + QSignalSpy told(colors->document()->documentLayout(), &QAbstractTextDocumentLayout::update); + colors->setLines(QVariant::fromValue(CodeColors::Lines{{"int a;\r", {{0, 3, 4}}}, {"int b;\r", {{0, 3, 4}}}})); + QCOMPARE(inkAt(1, 0), blue); + QCOMPARE(told.count(), 1); + colors->setInks({red, green, yellow, blue, magenta, cyan}); + QCOMPARE(told.count(), 2); + // No colors again, as for a plain text. + colors->setLines(QVariant::fromValue(CodeColors::Lines())); + QCOMPARE(inkAt(0, 0), QColor()); + } + + void previewerColorsCode() + { + // A source file comes with its colors, from bat: the first lines with the text, a long text's others after it; a plain text asks for none. + QTemporaryDir dir; + const QString ran = dir.filePath("ran"), hold = dir.filePath("hold"); + QVERIFY(mkfifo(QFile::encodeName(hold).constData(), 0600) == 0); + // Stands in for bat: every line in magenta, noting that it ran, and waiting at the hold itself before a 401st line, so that one stopped leaves nothing there. + const QByteArray magenta = "#!/bin/sh\necho ran >> '" + QFile::encodeName(ran) + "'\nn=0\n" + "while IFS= read -r line || [ -n \"$line\" ]; do\n" + " n=$((n + 1))\n" + " [ $n -eq 401 ] && read -r held < '" + QFile::encodeName(hold) + "'\n" + " printf '\\033[35m%s\\033[0m\\n' \"$line\"\n" + "done\n"; + const QString bin = binWith(dir.filePath("bin"), {{"bat", magenta}}); + const QString failing = binWith(dir.filePath("failing"), {{"bat", "#!/bin/sh\necho ran >> '" + QFile::encodeName(ran) + "'\nprintf '\\033[35mint\\033[0m\\n'\nexit 1\n"}}); + // One that colors the first text it is given and fails on every one after, noting those runs too. + const QString tiring = binWith(dir.filePath("tiring"), {{"bat", "#!/bin/sh\n[ -e '" + QFile::encodeName(ran) + "' ] && echo ran >> '" + QFile::encodeName(ran) + "' && exit 1\n" + magenta.mid(10)}}); + const QString stuck = binWith(dir.filePath("stuck"), {{"bat", "#!/bin/sh\nexec cat '" + QFile::encodeName(hold) + "'\n"}}); + const QString endless = binWith(dir.filePath("endless"), {{"bat", "#!/bin/sh\nwhile :; do printf '\\033[35m%s\\033[0m\\n' endless; done\n"}}); + // Two that print "ab" in magenta and end at once, saying so four times over before each letter, and one that says it only three times before the second. + const QByteArray said = "\\033[35m", four = said.repeated(4), unsaid = "\\033[0m"; + const QString wordy = binWith(dir.filePath("wordy"), {{"bat", "#!/bin/sh\nprintf '" + four + "a" + unsaid + four + "b" + unsaid + "\\n'\n"}}); + const QString terse = binWith(dir.filePath("terse"), {{"bat", "#!/bin/sh\nprintf '" + four + "a" + unsaid + said.repeated(3) + "b" + unsaid + "\\n'\n"}}); + // One that colors only when what it prints beside its colors goes nowhere. + const QString unheard = binWith(dir.filePath("unheard"), {{"bat", "#!/bin/sh\n[ /proc/self/fd/2 -ef /dev/null ] || exit 1\n" + magenta.mid(10)}}); + QDir(dir.path()).mkdir("none"); + QDir(dir.path()).mkdir("src"); + const QString src = dir.filePath("src"); + write(src + "/main.c", "int main() {}\n"); + write(src + "/notes.txt", "int main() {}\n"); + write(src + "/readme.md", "# int main\n"); + const int many = 1000; + QByteArray longText; + for (int i = 0; i < many; ++i) + longText += "line " + QByteArray::number(i) + "\n"; + write(src + "/long.py", longText); + // 400 lines that end, which the first lines hold whole, and 401 of which the last has no end. + const QByteArray fullText = longText.left(longText.indexOf("line 400\n")); + write(src + "/full.py", fullText); + write(src + "/edge.py", fullText + "x"); + // Lines so long that an opening holds 32 of them, and a first line that no opening holds. + const int wideLines = 100, wideLine = 500; + write(src + "/wide.py", (QByteArray(wideLine, 'w') + "\n").repeated(wideLines)); + write(src + "/min.py", QByteArray(Previewer::firstColoredSize + 1, 'm') + "\nvar = 1\n"); + // The same in a letter of three bytes: fewer lines to an opening, and a first line too long for one with half the letters an opening has bytes. + const QByteArray letter = "\xe6\x96\x87"; + const int letterLines = 40; + write(src + "/wide3.py", (letter.repeated(wideLine) + "\n").repeated(letterLines)); + write(src + "/min3.py", letter.repeated(Previewer::firstColoredSize / 2) + "\nvar = 1\n"); + // A second line that would fit an opening but for the line end ahead of it. + const QByteArray second = "var = 1"; + write(src + "/brim.py", QByteArray(Previewer::firstColoredSize - second.size(), 'b') + "\n" + second + "\nx = 2\n"); + // The other end of the hold, kept from when a stand-in is found waiting there until it is let go, and never handed to a stand-in started meanwhile. + int grip = -1; + const auto held = [&hold, &grip] { + if (grip < 0) + grip = ::open(QFile::encodeName(hold).constData(), O_WRONLY | O_NONBLOCK | O_CLOEXEC); + return grip >= 0; + }; + // Lets the stand-in past its hold; false while none has waited there. Asked once: a check that waits reads its condition again after it holds, and nobody waits there then. + const auto release = [&held, &grip] { + if (!held()) + return false; + ::close(grip); + grip = -1; + return true; + }; + using Runs = QVector; + // A stopped bat is gone well inside this, where one left alone has five seconds for a whole text. + const int stoppedMs = 2000; + const auto colorsOf = [](const Previewer &previewer) { return previewer.colors().value(); }; + // How many times a stand-in has run, once the readings under way are through. + const auto runs = [&ran] { + QThreadPool::globalInstance()->waitForDone(); + QCoreApplication::processEvents(); + QThreadPool::globalInstance()->waitForDone(); + QFile file(ran); + return file.open(QIODevice::ReadOnly) ? int(file.readAll().count('\n')) : 0; + }; + + { + const auto path = pathAt(bin); + Previewer previewer; + // How many lines had their colors each time a text was shown. + QVector shown; + connect(&previewer, &Previewer::changed, &previewer, [&] { + if (!previewer.text().isEmpty()) + shown << colorsOf(previewer).size(); + }); + previewer.start(src + "/main.c", false, "name"); + QTRY_COMPARE(previewer.text(), QString("int main() {}\n")); + // The text and its colors in one change, not one after the other. + QCOMPARE(shown, QVector{1}); + QCOMPARE(colorsOf(previewer).at(0).text, QString("int main() {}")); + QCOMPARE(colorsOf(previewer).at(0).runs, (Runs{{0, 13, 5}})); + // A text this short is whole in its first lines: bat isn't run on it again. + QCOMPARE(runs(), 1); + + // A plain text and a Markdown one: bat isn't run for them, and the colors of the file before are gone. + for (const char *name : {"/notes.txt", "/readme.md"}) { + QVERIFY(QFile::remove(ran)); + QSignalSpy told(&previewer, &Previewer::colored); + previewer.show(src + name); + QVERIFY(colorsOf(previewer).isEmpty()); + QVERIFY(!told.isEmpty()); + QTRY_VERIFY(!previewer.text().isEmpty()); + QThreadPool::globalInstance()->waitForDone(); + QVERIFY(colorsOf(previewer).isEmpty()); + QVERIFY(!QFile::exists(ran)); + previewer.show(src + "/main.c"); + QTRY_COMPARE(colorsOf(previewer).size(), 1); + } + + // A long text is shown with its first lines colored while bat is still on the whole of it. + QSignalSpy colored(&previewer, &Previewer::colored); + previewer.show(src + "/long.py"); + QTRY_VERIFY(!previewer.text().isEmpty()); + QCOMPARE(shown.last(), Previewer::firstColored); + QCOMPARE(colorsOf(previewer).size(), Previewer::firstColored); + QCOMPARE(colorsOf(previewer).last().text, QString("line 399")); + // The rest come once it is through, and nothing but the colors changes then. + const int texts = shown.size(), before = colored.count(); + QTRY_VERIFY(held()); + QVERIFY(release()); + QTRY_COMPARE(colorsOf(previewer).size(), many); + QCOMPARE(colored.count(), before + 1); + QCOMPARE(shown.size(), texts); + QCOMPARE(colorsOf(previewer).last().runs, (Runs{{0, 8, 5}})); + + // 400 lines are whole in the first lines, so bat isn't run again; a 401st with no end is one more to color. + QVERIFY(QFile::remove(ran)); + previewer.show(src + "/full.py"); + QTRY_COMPARE(colorsOf(previewer).size(), Previewer::firstColored); + QCOMPARE(runs(), 1); + previewer.show(src + "/edge.py"); + QTRY_COMPARE(colorsOf(previewer).size(), Previewer::firstColored); + QTRY_VERIFY(held()); + QVERIFY(release()); + QTRY_COMPARE(colorsOf(previewer).size(), Previewer::firstColored + 1); + QCOMPARE(colorsOf(previewer).last().runs, (Runs{{0, 1, 5}})); + + // Lines too long for 400 of them to be an opening: the whole lines that fit come with the text, the others after it. + previewer.show(src + "/wide.py"); + QTRY_VERIFY(!previewer.text().isEmpty()); + QCOMPARE(shown.last(), (Previewer::firstColoredSize + 1) / (wideLine + 1)); + QTRY_COMPARE(colorsOf(previewer).size(), wideLines); + // A first line too long for an opening: the text isn't kept waiting for bat, which colors all of it in its one run. + QCOMPARE(runs(), 5); + QVERIFY(QFile::remove(ran)); + previewer.show(src + "/min.py"); + QTRY_VERIFY(!previewer.text().isEmpty()); + QCOMPARE(shown.last(), 0); + QTRY_COMPARE(colorsOf(previewer).size(), 2); + QCOMPARE(runs(), 1); + // The size is of the bytes bat is sent, not of the letters: fewer lines of a letter of three bytes fit, and a line end counts. + previewer.show(src + "/wide3.py"); + QTRY_VERIFY(!previewer.text().isEmpty()); + QCOMPARE(shown.last(), (Previewer::firstColoredSize + 1) / (wideLine * int(letter.size()) + 1)); + QTRY_COMPARE(colorsOf(previewer).size(), letterLines); + previewer.show(src + "/min3.py"); + QTRY_VERIFY(!previewer.text().isEmpty()); + QCOMPARE(shown.last(), 0); + QTRY_COMPARE(colorsOf(previewer).size(), 2); + previewer.show(src + "/brim.py"); + QTRY_VERIFY(!previewer.text().isEmpty()); + QCOMPARE(shown.last(), 1); + QTRY_COMPARE(colorsOf(previewer).size(), 3); + QCOMPARE(runs(), 6); + + // Stepped past before its reading began: bat isn't run for a file nobody waited for. + { + QThreadPool *pool = QThreadPool::globalInstance(); + const int threads = pool->maxThreadCount(); + std::atomic busy = true; + const auto restore = qScopeGuard([&] { + busy = false; + pool->waitForDone(); + pool->setMaxThreadCount(threads); + }); + pool->setMaxThreadCount(1); + pool->start([&busy] { + while (busy) + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + }); + QVERIFY(QFile::remove(ran)); + previewer.show(src + "/full.py"); + previewer.show(src + "/main.c"); + busy = false; + QTRY_COMPARE(colorsOf(previewer).size(), 1); + QCOMPARE(runs(), 1); + } + + // Stepped on while bat is at the hold with the whole text: it is stopped, not left to finish a text nobody is looking at. + previewer.show(src + "/long.py"); + QTRY_COMPARE(colorsOf(previewer).size(), Previewer::firstColored); + QTRY_VERIFY(held()); + previewer.show(src + "/main.c"); + QTRY_COMPARE(colorsOf(previewer).size(), 1); + QVERIFY(QThreadPool::globalInstance()->waitForDone(stoppedMs)); + + // Stepped on and back with the hold kept: the run for the first showing is stopped, and one goes on alone. + previewer.show(src + "/long.py"); + QTRY_COMPARE(colorsOf(previewer).size(), Previewer::firstColored); + previewer.show(src + "/main.c"); + previewer.show(src + "/long.py"); + QTRY_COMPARE(colorsOf(previewer).size(), Previewer::firstColored); + QTRY_COMPARE_WITH_TIMEOUT(QThreadPool::globalInstance()->activeThreadCount(), 1, stoppedMs); + // That one is the second's: let past the hold, wherever it has got to, it colors the text shown. + QVERIFY(release()); + QTRY_VERIFY(colorsOf(previewer).size() == many || held()); + release(); + QTRY_COMPARE(colorsOf(previewer).size(), many); + + // Stepped on when bat is just through the whole text: its colors are for a file no longer shown, and are dropped. + previewer.show(src + "/main.c"); + previewer.show(src + "/long.py"); + QTRY_COMPARE(colorsOf(previewer).size(), Previewer::firstColored); + QVector since; + connect(&previewer, &Previewer::colored, &previewer, [&] { since << colorsOf(previewer).size(); }); + QTRY_VERIFY(held()); + QVERIFY(release()); + // Its answer is read and waits to be handed over when the next item is shown. + QThreadPool::globalInstance()->waitForDone(); + previewer.show(src + "/main.c"); + QTRY_COMPARE(colorsOf(previewer).size(), 1); + QCoreApplication::processEvents(); + QVERIFY(!since.contains(many)); + + // Closed while bat is at the hold with the whole text: it is stopped then too. + previewer.show(src + "/long.py"); + QTRY_COMPARE(colorsOf(previewer).size(), Previewer::firstColored); + QTRY_VERIFY(held()); + } + QVERIFY(QThreadPool::globalInstance()->waitForDone(stoppedMs)); + QVERIFY(release()); + + // Without bat, or with one that fails, the text is shown as it always was. + for (const QString &tools : {dir.filePath("none"), failing}) { + const auto path = pathAt(tools); + QFile::remove(ran); + Previewer previewer; + previewer.start(src + "/main.c", false, "name"); + QTRY_COMPARE(previewer.text(), QString("int main() {}\n")); + QVERIFY(colorsOf(previewer).isEmpty()); + QCOMPARE(previewer.viewer(), QString("text")); + // A long text too, and a bat that failed on its first lines isn't asked for the rest. + previewer.show(src + "/long.py"); + QTRY_COMPARE(previewer.text().size(), longText.size()); + QVERIFY(colorsOf(previewer).isEmpty()); + QCOMPARE(runs(), tools == failing ? 2 : 0); + QVERIFY(colorsOf(previewer).isEmpty()); + } + // When bat fails on the whole of a long text, the first lines keep the colors they have. + { + const auto path = pathAt(tiring); + QFile::remove(ran); + Previewer previewer; + previewer.start(src + "/long.py", false, "name"); + QTRY_COMPARE(colorsOf(previewer).size(), Previewer::firstColored); + QCOMPARE(runs(), 2); + QCOMPARE(colorsOf(previewer).size(), Previewer::firstColored); + } + // One that never answers is given up on at the limit, and at once when its colors aren't wanted any more. + const int longMs = 15000, soonMs = 10000; + { + const auto path = pathAt(stuck); + const int limitMs = 200; + QVERIFY(CodeColors::of("int main() {}\n", "main.c", limitMs).isEmpty()); + QElapsedTimer waited; + waited.start(); + QVERIFY(CodeColors::of("int main() {}\n", "main.c", longMs, [] { return false; }).isEmpty()); + QVERIFY(waited.elapsed() < soonMs); + // A text is held back by it no longer than its first lines are given, not the seconds the whole of it is. + Previewer previewer; + waited.start(); + previewer.start(src + "/long.py", false, "name"); + QTRY_COMPARE_WITH_TIMEOUT(previewer.text().size(), longText.size(), longMs); + QVERIFY(waited.elapsed() < stoppedMs); + QVERIFY(colorsOf(previewer).isEmpty()); + } + // One that prints without end is stopped when it has printed more than colors take. + { + const auto path = pathAt(endless); + QElapsedTimer waited; + waited.start(); + QVERIFY(CodeColors::of("int main() {}\n", "main.c", longMs).isEmpty()); + QVERIFY(waited.elapsed() < soonMs); + } + // One that prints more than that and ends before it is looked at is not read either: 51 bytes for the 3 sent, where 46 are still colors. + { + const auto path = pathAt(wordy); + QVERIFY(CodeColors::of("ab\n", "main.c", longMs).isEmpty()); + } + { + const auto path = pathAt(terse); + QCOMPARE(CodeColors::of("ab\n", "main.c", longMs).size(), 1); + } + // What bat prints beside its colors is never kept: this one colors only when it goes nowhere. + { + const auto path = pathAt(unheard); + QCOMPARE(CodeColors::of("int main() {}\n", "main.c", longMs).size(), 1); + } + const auto path = pathAt(bin); + QCOMPARE(CodeColors::of("int main() {}\n", "main.c", longMs).size(), 1); + } + + void previewWindowDrawsCodeInTheThemesInks() + { + // The preview's own window draws what bat colored in the inks of the theme. + QTemporaryDir dir; + // Stands in for bat: the first word of every line in magenta and the rest of it in green. + const QString bin = binWith(dir.filePath("bin"), {{"bat", "#!/bin/sh\n" + "while IFS= read -r line || [ -n \"$line\" ]; do\n" + " first=${line%% *}\n" + " printf '\\033[35m%s\\033[32m%s\\033[0m\\n' \"$first\" \"${line#\"$first\"}\"\n" + "done\n"}}); + QDir(dir.path()).mkpath("src"); + QDir(dir.path()).mkpath("current/theme"); + write(dir.filePath("src/main.c"), "int main() {}\n"); + write(dir.filePath("current/theme/colors.toml"), "foreground = \"#dddddd\"\nmagenta = \"#ff66ff\"\ngreen = \"#66ff66\"\n"); + const auto path = pathAt(bin); + qmlRegisterType("Folio", 1, 0, "CodeColors"); + Theme theme(dir.filePath("current")); + QCOMPARE(theme.green(), QColor("#66ff66")); + // A theme that names no green gets the one the other fallback colors go with. + QCOMPARE(Theme(dir.filePath("none")).green(), QColor("#9ece6a")); + Previewer previewer; + previewer.start(dir.filePath("src/main.c"), false, "name"); + QQmlEngine engine; + engine.rootContext()->setContextProperty("theme", &theme); + engine.rootContext()->setContextProperty("previewer", &previewer); + engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); + QQmlComponent component(&engine, QUrl::fromLocalFile(sourceFolder() + "/PreviewWindow.qml")); + std::unique_ptr root(component.create()); + QVERIFY2(root, qPrintable(component.errorString())); + auto *window = qobject_cast(root.get()); + QVERIFY(window); + QTRY_COMPARE(previewer.text(), QString("int main() {}\n")); + // The text view, found once the window has made it for a text. + const std::function textIn = [&textIn](QQuickItem *item) -> QQuickItem * { + if (item->inherits("QQuickTextEdit")) + return item; + for (QQuickItem *child : item->childItems()) + if (QQuickItem *found = textIn(child)) + return found; + return nullptr; + }; + QTRY_VERIFY(textIn(window->contentItem())); + QTextDocument *document = textIn(window->contentItem())->property("textDocument").value()->textDocument(); + const auto inkAt = [document](int column) { + const auto formats = document->firstBlock().layout()->formats(); + for (const QTextLayout::FormatRange &range : formats) + if (range.start <= column && column < range.start + range.length) + return range.format.foreground().color(); + return QColor(); + }; + QTRY_COMPARE(document->firstBlock().text(), QString("int main() {}")); + QCOMPARE(inkAt(0), QColor("#ff66ff")); + QCOMPARE(inkAt(2), QColor("#ff66ff")); + QCOMPARE(inkAt(3), QColor("#66ff66")); + QCOMPARE(inkAt(12), QColor("#66ff66")); + + // Another theme is picked while the preview is open: the code is redrawn in its colors. + write(dir.filePath("current/theme/colors.toml"), "foreground = \"#dddddd\"\nmagenta = \"#ffaaff\"\ngreen = \"#aaffaa\"\n"); + QTRY_COMPARE(inkAt(0), QColor("#ffaaff")); + QCOMPARE(inkAt(12), QColor("#aaffaa")); + } + void previewerCountsHiddenFilesWhenTheyShow() { QTemporaryDir dir; @@ -5817,6 +6479,62 @@ private slots: QVERIFY(theme.light()); } + void themeDrawsCodeInInksThatReadOnItsBackgrounds() + { + // Code is drawn in the theme's own six colors where they can be read, and in the nearest that can where they can't. + QTemporaryDir dir; + QDir(dir.path()).mkpath("theme"); + const auto inksOf = [](const Theme &theme) { + QVector inks; + for (const QVariant &ink : theme.codeInks()) + inks << ink.value(); + return inks; + }; + // WCAG's contrast of two colors, from 1 to 21. + const auto contrast = [](const QColor &a, const QColor &b) { + const auto light = [](const QColor &c) { + const auto linear = [](double v) { return v <= .04045 ? v / 12.92 : std::pow((v + .055) / 1.055, 2.4); }; + return .2126 * linear(c.redF()) + .7152 * linear(c.greenF()) + .0722 * linear(c.blueF()); + }; + return (qMax(light(a), light(b)) + .05) / (qMin(light(a), light(b)) + .05); + }; + // What reads, and what is no further from the theme's color than reading takes. + const double reads = 4.5, near = 4.6; + + // A dark theme whose colors all read on it: its own, in the order bat numbers them. + write(dir.filePath("theme/colors.toml"), "background = \"#1e1e2e\"\ndark_background = \"#161622\"\nforeground = \"#cdd6f4\"\n" + "red = \"#f38ba8\"\ngreen = \"#a6e3a1\"\nyellow = \"#f9e2af\"\nblue = \"#89b4fa\"\nmagenta = \"#f5c2e7\"\ncyan = \"#94e2d5\"\n"); + Theme theme(dir.path()); + QCOMPARE(inksOf(theme), (QVector{QColor("#f38ba8"), QColor("#a6e3a1"), QColor("#f9e2af"), QColor("#89b4fa"), QColor("#f5c2e7"), QColor("#94e2d5")})); + + // The theme changes to a light one while it is in use: a yellow too faint for its paper is darkened until it reads, and a red that reads is left alone. + QSignalSpy changed(&theme, &Theme::changed); + write(dir.filePath("theme/colors.toml"), "mode = \"light\"\nbackground = \"#eff1f5\"\ndark_background = \"#e6e9ef\"\nforeground = \"#4c4f69\"\n" + "red = \"#a00020\"\nyellow = \"#df8e1d\"\n"); + QTRY_VERIFY(!changed.isEmpty()); + const QColor paper("#eff1f5"), darkPaper("#e6e9ef"), yellow("#df8e1d"); + QCOMPARE(inksOf(theme).at(0), QColor("#a00020")); + const QColor darkened = inksOf(theme).at(2); + QVERIFY(contrast(yellow, darkPaper) < reads); + QVERIFY(contrast(darkened, paper) >= reads); + // The dark background is the one a dark ink shows less on, so it decides, and the ink goes no darker than it asks. + QVERIFY(contrast(darkened, darkPaper) >= reads); + QVERIFY(contrast(darkened, darkPaper) < near); + QVERIFY(qAbs(darkened.hslHue() - yellow.hslHue()) <= 2); + // A color the theme doesn't name is Folio's own for it, held to the same. + QVERIFY(contrast(inksOf(theme).at(1), darkPaper) >= reads); + + // On a dark theme a red too deep for it is lightened, and there the background decides. + write(dir.filePath("theme/colors.toml"), "background = \"#1f1f28\"\ndark_background = \"#16161d\"\nforeground = \"#dcd7ba\"\nred = \"#c34043\"\n"); + const QColor night("#1f1f28"), red("#c34043"); + QTRY_VERIFY(inksOf(theme).at(0) != QColor("#a00020")); + const QColor lightened = inksOf(theme).at(0); + QVERIFY(contrast(red, night) < reads); + QVERIFY(contrast(lightened, night) >= reads); + QVERIFY(contrast(lightened, night) < near); + QVERIFY(lightened.lightnessF() > red.lightnessF()); + } + void themeDerivesALighterBackgroundWhenItMatches() { QTemporaryDir dir;