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;