Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/Format.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<font color="#ff00ff">int</font>&nbsp;a;'.
function colored(text, runs, inks) {
const kept = (part) => part.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/ /g, "&nbsp;").replace(/\t/g, "&#9;").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)) + '<font color="' + inks[ink - 1] + '">' + kept(line.slice(start, start + length)) + "</font>"
at = start + length
}
return out + kept(line.slice(at))
}).join("<br>")
}

// A key combination as people read it: "Ctrl + C", not "Ctrl+C".
function keys(text) {
return text.replace(/(\S)\+(?=\S)/g, "$1 + ")
Expand Down
7 changes: 5 additions & 2 deletions src/PreviewPane.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/PreviewWindow.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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`):
Expand Down Expand Up @@ -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 { }
}
Expand Down
12 changes: 10 additions & 2 deletions src/backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@
#include <QtConcurrent>
#include <unistd.h>

// 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);
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}));
Expand Down
182 changes: 182 additions & 0 deletions src/codecolors.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
#include "codecolors.h"

#include <QElapsedTimer>
#include <QProcess>
#include <QTextBlock>
#include <QTextDocument>

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<bool()> &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<Lines>();
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<QTextLayout::FormatRange> CodeColors::formatsOf(int number, const QString &text) const {
QList<QTextLayout::FormatRange> 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<QColor>());
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());
}
70 changes: 70 additions & 0 deletions src/codecolors.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
#pragma once

#include <QColor>
#include <QQuickTextDocument>
#include <QSyntaxHighlighter>
#include <QTextLayout>
#include <QVariant>
#include <QVector>
#include <functional>

// 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<Run> runs;
bool operator==(const Line &other) const { return text == other.text && runs == other.runs; }
};
using Lines = QVector<Line>;

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<bool()> &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<QTextLayout::FormatRange> formatsOf(int number, const QString &text) const;
void repaint();

QQuickTextDocument *m_textDocument = nullptr;
Lines m_lines;
QVariantList m_inks;
};

Q_DECLARE_METATYPE(CodeColors::Lines)
1 change: 1 addition & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodeColors>("Folio", 1, 0, "CodeColors");
engine.load(QUrl("qrc:/PreviewWindow.qml"));
if (engine.rootObjects().isEmpty())
return 1;
Expand Down
Loading