From ea4d05bd4cbf9dd401f1f25c7dad17826b1c9f7a Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:32:18 -0400 Subject: [PATCH 1/3] Show the names inside an archive in its preview An archive showed only its icon. Selecting one now lists the names inside it, in the details pane and in the preview window. bsdtar reads them off the UI thread, they show as they arrive, and the read stops when the selection moves on. --- README.md | 2 +- src/PreviewPane.qml | 18 +++- src/PreviewWindow.qml | 17 +-- src/Sentence.qml | 13 +++ src/backend.cpp | 24 ++++- src/backend.h | 12 ++- src/i18n/en.json | 18 ++++ src/previewer.cpp | 214 ++++++++++++++++++++++++++++++++++++ src/previewer.h | 66 +++++++++++- src/resources.qrc | 1 + tests/folio_tests.cpp | 245 +++++++++++++++++++++++++++++++++++++++++- 11 files changed, 611 insertions(+), 19 deletions(-) create mode 100644 src/Sentence.qml diff --git a/README.md b/README.md index ef59b417..3476b78c 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Files (`folio`) is Omarchy's file manager. It's one quiet window that follows your Omarchy theme and language, works from the keyboard, and never crawls your home folder in the background: folders are read when you open them, and thumbnails are made when a tile needs one, shared with every other app through `~/.cache/thumbnails`. - **Three views**: a grid with real previews, a list, and Finder-style columns. Ctrl+1/2/3 switches. -- **Space previews** pictures, video, audio, PDFs, text and Markdown at full size, in their own window. +- **Space previews** pictures, video, audio, PDFs, text and Markdown at full size, and the names inside archives, in their own window. - **The sidebar** holds everything in your home folder, pinned folders (shared with GTK file dialogs), Recents, Trash with restore, your drives, and the machines on your tailnet, which open over SFTP. - **Right-click** (or Alt+Enter) to open with another app, rename, duplicate, compress, share by Dropbox link, Taildrop or LocalSend, and delete. Copy and paste work with other apps, and nothing is ever overwritten. - **Split** the window in two with Ctrl+Alt+N to move things between folders. diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 76e7aea0..397890a5 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -43,6 +43,9 @@ Rectangle { // A picture's size or a text's opening lines once they've been read off // the UI thread. readonly property var info: backend.details + // Text files show their opening lines in a frame, archives bsdtar can list the names inside. + readonly property bool archive: hasItem && backend.listsArchive(item.mime) + readonly property bool listed: hasItem && (item.kind === "text" || archive) // Described once the folder's loaded has put the cursor back on its // item: a re-read leaves another item on the cursor's row for a moment, // and describing that would drop what's been read of this one. @@ -68,14 +71,14 @@ Rectangle { anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectFit - source: pane.hasItem && !pane.item.isDir && pane.item.kind !== "text" && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" + source: pane.hasItem && !pane.item.isDir && !pane.listed && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" visible: status === Image.Ready } - // Text files show their opening lines instead of a picture. + // Text files show their opening lines instead of a picture, archives the names inside. Rectangle { anchors.fill: parent - visible: pane.hasItem && pane.item.kind === "text" + visible: pane.listed color: theme.background border.width: 1 border.color: Qt.alpha(theme.foreground, 0.12) @@ -90,11 +93,18 @@ Rectangle { textFormat: Text.PlainText elide: Text.ElideRight } + Sentence { + visible: pane.archive && !pane.info.text + margin: 14 + text: pane.info.sentence || qsTr("Reading the archive…") + color: theme.secondaryForeground + font.pixelSize: 12 + } } Glyph { anchors.centerIn: parent - visible: !big.visible && pane.hasItem && pane.item.kind !== "text" + visible: !big.visible && pane.hasItem && !pane.listed name: pane.pluginItem ? pane.item.icon : pane.hasItem && pane.item.isDir ? "folder" : "file" size: 72 stroke: 1.2 diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index eb7530e3..9997fc9e 100644 --- a/src/PreviewWindow.qml +++ b/src/PreviewWindow.qml @@ -143,7 +143,7 @@ ApplicationWindow { anchors.fill: parent active: !win.external sourceComponent: win.viewer === "image" || win.viewer === "animated" ? imageView - : win.viewer === "text" || win.viewer === "markdown" ? textView + : win.viewer === "text" || win.viewer === "markdown" || win.viewer === "archive" ? textView : win.viewer === "folder" ? folderView : otherView } @@ -167,6 +167,13 @@ ApplicationWindow { : qsTr("Playing video and audio needs Qt Multimedia, from the qt6-multimedia package.") color: theme.foreground } + + // An archive's names are read as bsdtar prints them. + Sentence { + visible: win.viewer === "archive" && !previewer.text + text: previewer.sentence || qsTr("Reading the archive…") + color: previewer.sentence ? theme.foreground : theme.secondaryForeground + } } } @@ -270,7 +277,7 @@ ApplicationWindow { } } - // Text to read, Markdown rendered; selectable, never editable. + // Text to read, Markdown rendered, an archive's names; selectable, never editable. Component { id: textView @@ -284,11 +291,7 @@ ApplicationWindow { function scroll(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * 60)) } function page(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * (height - 60))) } - Connections { - target: previewer - function onChanged() { flick.contentY = 0 } - } - + // Never sent to the top when the text changes: an archive's names keep arriving, and another file empties the text first, which brings the view back by itself, even one still gliding. TextEdit { id: body width: Math.min(flick.width - 96, win.viewer === "markdown" ? 820 : 1200) diff --git a/src/Sentence.qml b/src/Sentence.qml new file mode 100644 index 00000000..74328f23 --- /dev/null +++ b/src/Sentence.qml @@ -0,0 +1,13 @@ +import QtQuick + +// A sentence in the middle of an empty view: what it waits for, that it's empty, or why not. +Text { + // Kept clear on either side. + property int margin: 48 + + anchors.centerIn: parent + width: parent.width - 2 * margin + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + color: theme.foreground +} diff --git a/src/backend.cpp b/src/backend.cpp index 6224ee0c..4659c9d2 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -363,6 +363,8 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { const QString kind = item.value("kind").toString(); const QString mime = item.value("mime").toString(); const quint64 generation = ++*m_describing; + // Whatever a helper program was reading for the last item goes unread. + m_helper.stop(); // The same item again (its folder re-read) keeps what it shows until // the new reading is in, rather than flickering. QVariantMap details; @@ -376,19 +378,37 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { // a plugin's own items. m_pluginItem = path.startsWith('/') && !inTrash ? item : QVariantMap(); showPluginRows(askPlugins()); - if (kind != "image" && kind != "text" && !inTrash) + // What a helper program reads comes on top of the rest. + const auto help = [this, path, mime](const QVariantMap &details) { + // A helper program reads a file on the disk: a plugin's own item isn't one. + if (!path.startsWith('/')) + return; + // An archive's names, shown as bsdtar prints them. + if (Previewer::listsArchive(mime)) { + Previewer::listArchive(m_helper, path, [this, details](const QString &text, const QString &sentence) { + m_details = details; + m_details.insert("text", text); + m_details.insert("sentence", sentence); + emit detailsChanged(); + }); + } + }; + if (kind != "image" && kind != "text" && !inTrash) { + help(details); return; + } // Reading the file waits on its disk, which for a slow mount would hold // up moving through files, so it happens off the UI thread. auto *watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, details]() mutable { + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation, details, help]() mutable { watcher->deleteLater(); if (generation != *m_describing) return; // The selection moved on while this was read. details.insert(watcher->result()); m_details = details; emit detailsChanged(); + help(details); }); // Stepping through files faster than they're read queues readings; the // ones overtaken before they start are skipped rather than left to tie diff --git a/src/backend.h b/src/backend.h index 7d0f582d..25db4cc4 100644 --- a/src/backend.h +++ b/src/backend.h @@ -9,6 +9,7 @@ #include "foldermodel.h" #include "mounter.h" #include "plugins.h" +#include "previewer.h" // Where the window is and how it got there: the current folder, its // breadcrumbs, back/forward history, and the actions that leave the folder @@ -29,9 +30,10 @@ class Backend : public QObject { Q_PROPERTY(bool previewing READ previewing NOTIFY previewingChanged) // What the details pane shows beyond the listing, for the item last // given to describe(), once they've been read in the background: a - // picture's dimensions, a text's opening lines, or a trashed item's - // origin and deletion time; and under `plugins`, plugins' rows about a - // file or folder, [[label, value]...]. + // picture's dimensions, a text's opening lines, an archive's names (or + // a sentence in their place), or a trashed item's origin and deletion + // time; and under `plugins`, plugins' rows about a file or folder, + // [[label, value]...]. Q_PROPERTY(QVariantMap details READ details NOTIFY detailsChanged) public: @@ -76,6 +78,8 @@ class Backend : public QObject { // The system's own name for a type ("JPEG image"), in the language of // the moment. Q_INVOKABLE static QString typeName(const QString &mime); + // Whether the details list an archive of this type's names. + Q_INVOKABLE static bool listsArchive(const QString &mime) { return Previewer::listsArchive(mime); } // The item to select once the folder has loaded (where we came from, or // what "Show in folder" asked for); empty means the first item. Q_INVOKABLE QString takeSelection(); @@ -185,4 +189,6 @@ class Backend : public QObject { // The pane the working directory follows: the one used last. static inline Backend *s_working = nullptr; void moveWorkingDirectory(); + // Reads what only a helper program can, for the item described last. + Helper m_helper{this}; }; diff --git a/src/i18n/en.json b/src/i18n/en.json index 369dbda5..f81a0251 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -40,11 +40,23 @@ "one": "%Ln item will be deleted permanently. This can't be undone.", "other": "%Ln items will be deleted permanently. This can't be undone." }, + "%Ln more": { + "one": "%Ln more", + "other": "%Ln more" + }, "A folder, or a server's address like sftp://host/": "A folder, or a server's address like sftp://host/", "A name can't contain \"/\".": "A name can't contain \"/\".", "Add or remove one from the selection": "Add or remove one from the selection", "Alt+click": "Alt+click", "Archive": "Archive", + "At least %Ln items": { + "one": "At least %Ln item", + "other": "At least %Ln items" + }, + "At least %Ln more": { + "one": "At least %Ln more", + "other": "At least %Ln more" + }, "Back / ahead 5 seconds": "Back / ahead 5 seconds", "Back / forward (or the mouse's thumb buttons)": "Back / forward (or the mouse's thumb buttons)", "Bigger, smaller or normal tiles (or Ctrl+scroll)": "Bigger, smaller or normal tiles (or Ctrl+scroll)", @@ -118,6 +130,7 @@ "Couldn't open this PDF: %1": "Couldn't open this PDF: %1", "Couldn't play this file: %1": "Couldn't play this file: %1", "Couldn't read %1.": "Couldn't read %1.", + "Couldn't read this archive.": "Couldn't read this archive.", "Couldn't read this picture.": "Couldn't read this picture.", "Couldn't recreate %1.": "Couldn't recreate %1.", "Couldn't remove %1.": "Couldn't remove %1.", @@ -159,6 +172,7 @@ "one": "Emptied the trash: %Ln item deleted.", "other": "Emptied the trash: %Ln items deleted." }, + "Empty": "Empty", "Empty Trash": "Empty Trash", "Empty Trash…": "Empty Trash…", "Empty the trash?": "Empty the trash?", @@ -214,6 +228,8 @@ "Keep Both": "Keep Both", "Keyboard shortcuts": "Keyboard shortcuts", "Kind": "Kind", + "Listing archives needs bsdtar, from the libarchive package.": "Listing archives needs bsdtar, from the libarchive package.", + "Listing this archive took too long.": "Listing this archive took too long.", "Making a preview…": "Making a preview…", "Merge": "Merge", "Merging adds what's in this folder to the one there. What has the same name inside is replaced, and the replaced items go to the trash.": "Merging adds what's in this folder to the one there. What has the same name inside is replaced, and the replaced items go to the trash.", @@ -286,6 +302,7 @@ "Previewing PDFs needs Qt PDF, from the qt6-webengine package.": "Previewing PDFs needs Qt PDF, from the qt6-webengine package.", "Previous (%1)": "Previous (%1)", "Previous / next file": "Previous / next file", + "Reading the archive…": "Reading the archive…", "Recents": "Recents", "Redid compressing into %1.": "Redid compressing into %1.", "Redid copying %Ln items.": { @@ -386,6 +403,7 @@ "The drive is gone.": "The drive is gone.", "The drive stopped taking data (%1).": "The drive stopped taking data (%1).", "The drive was changed or removed.": "The drive was changed or removed.", + "The names in this archive are encrypted.": "The names in this archive are encrypted.", "The preview closed unexpectedly on %1.": "The preview closed unexpectedly on %1.", "There's nothing in %1 to extract.": "There's nothing in %1 to extract.", "There's nothing to redo.": "There's nothing to redo.", diff --git a/src/previewer.cpp b/src/previewer.cpp index 7ac8bf0e..b36110b9 100644 --- a/src/previewer.cpp +++ b/src/previewer.cpp @@ -8,7 +8,11 @@ #include #include #include +#include +#include +#include #include +#include #include Previewer::Previewer(QObject *parent) : QObject(parent) {} @@ -34,6 +38,8 @@ QString Previewer::viewerFor(const QString &mime, bool isDir) { return "image"; return "thumbnail"; } + if (listsArchive(mime)) + return "archive"; if (mime == "text/markdown") return "markdown"; const QMimeType type = QMimeDatabase().mimeTypeForName(mime); @@ -42,6 +48,20 @@ QString Previewer::viewerFor(const QString &mime, bool isDir) { return "other"; } +bool Previewer::listsArchive(const QString &mime) { + static const QStringList types{"application/zip", "application/x-tar", "application/x-compressed-tar", + "application/x-xz-compressed-tar", "application/x-bzip2-compressed-tar", + "application/x-zstd-compressed-tar", "application/x-lz4-compressed-tar", + "application/x-7z-compressed", "application/vnd.rar", "application/x-iso9660-image", + "application/x-cpio"}; + // By the names the system's database gives, older or newer (an ISO is application/vnd.efi.iso in newer ones). + const QMimeDatabase mimes; + const QString name = mimes.mimeTypeForName(mime).name(); + return !name.isEmpty() && std::any_of(types.begin(), types.end(), [&](const QString &type) { + return mimes.mimeTypeForName(type).name() == name; + }); +} + QString Previewer::readText(const QString &path, qint64 limit) { QFile file(path); if (!file.open(QIODevice::ReadOnly)) @@ -208,6 +228,15 @@ 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_sentence.clear(); + m_helper.stop(); + if (m_viewer == "archive") { + listArchive(m_helper, e.path, [this](const QString &text, const QString &sentence) { + m_text = text; + m_sentence = sentence; + emit changed(); + }); + } // 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, @@ -271,3 +300,188 @@ void Previewer::connectToFolio() { } }); } + +// An archive's listing is cut there; bsdtar prints each name on a line. +static constexpr qint64 archiveOutputLimit = 32 * 1024 * 1024; + +Helper::Helper(QObject *parent, int milliseconds) : QObject(parent) { + m_limit.setSingleShot(true); + m_limit.setInterval(milliseconds); + connect(&m_limit, &QTimer::timeout, this, [this] { end(stopped, {}); }); +} + +Helper::~Helper() { + stop(); +} + +void Helper::run(const QString &program, const QStringList &args, qint64 outputLimit, + const std::function &output, + const std::function &finished) { + stop(); + m_read = 0; + m_outputLimit = outputLimit; + m_errors.clear(); + m_finished = finished; + auto *process = new QProcess(this); + m_process = process; + // UTF-8 names print as they are, whatever Folio's language. + QProcessEnvironment environment = QProcessEnvironment::systemEnvironment(); + environment.insert("LC_ALL", "C.UTF-8"); + process->setProcessEnvironment(environment); + // No terminal or input to ask for a passphrase on. + process->setStandardInputFile(QProcess::nullDevice()); + process->setUnixProcessParameters(QProcess::UnixProcessFlag::CreateNewSession); + // It goes with Folio, even when Folio crashes. + process->setChildProcessModifier([] { ::prctl(PR_SET_PDEATHSIG, SIGKILL); }); + const auto take = [this, process, output] { + const QByteArray part = process->readAllStandardOutput(); + m_read += part.size(); + if (m_read > m_outputLimit) + return false; + if (!part.isEmpty()) + output(part); + return true; + }; + connect(process, &QProcess::readyReadStandardOutput, this, [this, take] { + if (!take()) + end(stopped, {}); + }); + // Read as it arrives, so a program that prints errors without end fills no memory. + const auto keep = [this, process] { + const QByteArray part = process->readAllStandardError(); + m_errors += part.left(errorsKept - m_errors.size()); + }; + connect(process, &QProcess::readyReadStandardError, this, keep); + connect(process, &QProcess::finished, this, [this, take, keep](int code, QProcess::ExitStatus status) { + if (!take()) { + end(stopped, {}); + return; + } + keep(); + end(status == QProcess::NormalExit ? code : crashed, std::exchange(m_errors, {})); + }); + // A program that isn't installed never finishes, so this is the only word from it. + connect(process, &QProcess::errorOccurred, this, [this](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) + end(missing, {}); + }); + m_limit.start(); + process->start(program, args); +} + +void Helper::stop() { + m_limit.stop(); + m_finished = nullptr; + QProcess *process = std::exchange(m_process, nullptr); + if (!process) + return; + process->disconnect(this); + if (process->state() == QProcess::NotRunning) { + process->deleteLater(); + return; + } + // Killed without waiting on a hung disk; the object goes once the process has exited, or has failed to start. + process->setParent(nullptr); + connect(process, &QProcess::finished, process, &QObject::deleteLater); + connect(process, &QProcess::errorOccurred, process, [process](QProcess::ProcessError error) { + if (error == QProcess::FailedToStart) + process->deleteLater(); + }); + // It leads a session of its own, so what it started (a program bsdtar unpacks with) goes with it. + if (const qint64 pid = process->processId(); pid > 0) + ::killpg(pid_t(pid), SIGKILL); + process->kill(); +} + +void Helper::end(int code, const QByteArray &errors) { + const auto finished = m_finished; + stop(); + if (finished) + finished(code, errors); +} + +bool ArchiveNames::add(const QByteArray &output) { + // What was left had no line end in it: the look for one starts at the new part. + qsizetype from = m_pending.size(); + m_pending += output; + const qsizetype before = m_names.size(); + qsizetype start = 0; + qsizetype newline; + while ((newline = m_pending.indexOf('\n', from)) >= 0) { + const QByteArrayView line(m_pending.constData() + start, newline - start); + start = from = newline + 1; + // The "./" a tar made with `-C folder .` starts with, or an ISO's ".", is the archive itself. + if (line.isEmpty() || line == "./" || line == ".") + continue; + ++m_count; + // Names are shown up to a count and up to a size together: a few very long ones would hold the window as a huge text does. + m_full = m_full || m_names.size() == shown || m_bytes + line.size() > shownBytes; + if (m_full) + continue; + m_bytes += line.size(); + m_names << nameFrom(line); + } + m_pending.remove(0, start); + return m_names.size() > before; +} + +QString ArchiveNames::text(bool whole) const { + const int count = int(qMin(m_count, INT_MAX)); + const int more = count - int(m_names.size()); + QStringList lines{whole ? Previewer::tr("%Ln items", nullptr, count) : Previewer::tr("At least %Ln items", nullptr, count), + QString()}; + lines += m_names; + if (more > 0) + lines << (whole ? Previewer::tr("%Ln more", nullptr, more) : Previewer::tr("At least %Ln more", nullptr, more)); + return lines.join('\n'); +} + +// Sample: `./sub dir/caf\303\251 \\ two\nlines.txt` reads `sub dir/café \ two\nlines.txt` ("\n" stays). +QString ArchiveNames::nameFrom(QByteArrayView line) { + if (line.startsWith("./")) + line = line.sliced(2); + const auto octal = [&](qsizetype at) { return at < line.size() && line[at] >= '0' && line[at] <= '7'; }; + QByteArray name; + name.reserve(line.size()); + for (qsizetype i = 0; i < line.size(); ++i) { + if (line[i] == '\\' && i + 1 < line.size() && line[i + 1] == '\\') { + name += '\\'; + ++i; + } else if (line[i] == '\\' && octal(i + 1) && octal(i + 2) && octal(i + 3)) { + name += char((line[i + 1] - '0') * 64 + (line[i + 2] - '0') * 8 + (line[i + 3] - '0')); + i += 3; + } else { + name += line[i]; + } + } + return QString::fromUtf8(name); +} + +void Previewer::listArchive(Helper &helper, const QString &path, + const std::function &show) { + auto names = std::make_shared(); + // An absolute path is never read as an option, or as "-" for stdin. + helper.run("bsdtar", {"-tf", QFileInfo(path).absoluteFilePath()}, archiveOutputLimit, + [names, show](const QByteArray &output) { + if (names->add(output)) + show(names->text(false), {}); + }, + [names, show](int code, const QByteArray &errors) { + if (code == 0 && names->count() > 0) + show(names->text(true), {}); + else if (code == 0) + show({}, tr("Empty")); + else if (code == Helper::stopped && names->count() > 0) + show(names->text(false), {}); + // Stopped before its first name: a large archive on a slow drive, not a damaged one. + else if (code == Helper::stopped) + show({}, tr("Listing this archive took too long.")); + else if (code == Helper::missing) + show({}, tr("Listing archives needs bsdtar, from the libarchive package.")); + // A failed run shows no names: bsdtar reads a renamed text file as a list of names. + else if (errors.contains("ncrypt")) + show({}, tr("The names in this archive are encrypted.")); + else + show({}, tr("Couldn't read this archive.")); + }); +} diff --git a/src/previewer.h b/src/previewer.h index 9aee60e9..3eb9788c 100644 --- a/src/previewer.h +++ b/src/previewer.h @@ -1,12 +1,66 @@ #pragma once #include +#include #include +#include #include #include +#include #include "foldermodel.h" +// A helper program (bsdtar, ffprobe) reading the shown item, stopped unread when the view moves on or goes. +class Helper : public QObject { +public: + // How a run ended when it didn't exit by itself. + static constexpr int crashed = -1, stopped = -2, missing = -3; + // As much of stderr as a run keeps for its caller; the rest is read and dropped. + static constexpr qint64 errorsKept = 64 * 1024; + + explicit Helper(QObject *parent = nullptr, int milliseconds = 5000); + ~Helper() override; + + // Stops the last run; `output` gets stdout as it arrives, `finished` the exit code and stderr. + void run(const QString &program, const QStringList &args, qint64 outputLimit, + const std::function &output, + const std::function &finished); + void stop(); + +private: + void end(int code, const QByteArray &errors); + + QProcess *m_process = nullptr; + QTimer m_limit; + qint64 m_read = 0; + qint64 m_outputLimit = 0; + QByteArray m_errors; + std::function m_finished; +}; + +// An archive's names from `bsdtar -tf`, read as they arrive: the first kept, the rest counted. +class ArchiveNames { +public: + static constexpr int shown = 500; + // As much as a text file shows of itself. + static constexpr qint64 shownBytes = 128 * 1024; + + // Takes the next part of the output; true when it adds a name to those shown. + bool add(const QByteArray &output); + qint64 count() const { return m_count; } + // The count, a blank line, the names and how many more; "at least" until it's whole. + QString text(bool whole) const; + // One line of `bsdtar -tf` back to the name it escapes. + static QString nameFrom(QByteArrayView line); + +private: + QByteArray m_pending; + QStringList m_names; + qint64 m_count = 0; + qint64 m_bytes = 0; + bool m_full = false; +}; + // The state behind `folio preview`: the file being looked at, its siblings // to step through, and which viewer shows it. It runs in its own process, as // Sushi does for Nautilus, so a video or PDF that crashes a decoder takes the @@ -27,6 +81,8 @@ class Previewer : public QObject { Q_PROPERTY(QString url READ url NOTIFY changed) Q_PROPERTY(QString text READ text NOTIFY changed) Q_PROPERTY(QString type READ type NOTIFY changed) + // Said in place of an archive's names: it's empty, can't be read, or needs bsdtar. + Q_PROPERTY(QString sentence READ sentence NOTIFY changed) public: explicit Previewer(QObject *parent = nullptr); @@ -49,14 +105,17 @@ class Previewer : public QObject { QString url() const; QString text() const { return m_text; } QString type() const; + QString sentence() const { return m_sentence; } Q_INVOKABLE void next(); Q_INVOKABLE void previous(); Q_INVOKABLE void open(); // Which view shows a type: image, animated, thumbnail, video, audio, - // pdf, markdown, text, folder or other. + // pdf, markdown, text, archive, folder or other. static QString viewerFor(const QString &mime, bool isDir); + // Whether `bsdtar -tf` lists an archive of this type; a lone .gz, .xz or .zst isn't one. + static bool listsArchive(const QString &mime); // The opening part of a text file, ending at a line when there's more, // or nothing when it looks binary. Laying out text takes the UI thread // about a third of a millisecond per KB, so the preview reads 128 KB. @@ -65,6 +124,9 @@ 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); + // Lists an archive with `helper`: `show` gets the names so far, then all, or a sentence instead. + static void listArchive(Helper &helper, const QString &path, + const std::function &show); signals: void changed(); @@ -77,6 +139,8 @@ class Previewer : public QObject { int m_index = -1; QString m_viewer; QString m_text; + QString m_sentence; + Helper m_helper{this}; bool m_showHidden = false; QString m_sort = "name"; quint64 m_generation = 0; diff --git a/src/resources.qrc b/src/resources.qrc index a4118dfc..0272632c 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -22,6 +22,7 @@ FolioScrollBar.qml PlainButton.qml PlainField.qml + Sentence.qml SortMenu.qml FileMenu.qml FolioDialog.qml diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 21e81d07..b06942b9 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -29,6 +29,7 @@ #include #include #include +#include #include #include #include @@ -5552,7 +5553,15 @@ private slots: QCOMPARE(Previewer::viewerFor("text/markdown", false), QString("markdown")); QCOMPARE(Previewer::viewerFor("text/x-c++src", false), QString("text")); QCOMPARE(Previewer::viewerFor("application/x-shellscript", false), QString("text")); - QCOMPARE(Previewer::viewerFor("application/zip", false), QString("other")); + QCOMPARE(Previewer::viewerFor("application/zip", false), QString("archive")); + QCOMPARE(Previewer::viewerFor("application/x-compressed-tar", false), QString("archive")); + QCOMPARE(Previewer::viewerFor("application/x-xz-compressed-tar", false), QString("archive")); + QCOMPARE(Previewer::viewerFor("application/x-cpio", false), QString("archive")); + QCOMPARE(Previewer::viewerFor("application/x-iso9660-image", false), QString("archive")); + // bsdtar can't list a lone compressed file, so it stays as 0.2.0 showed it. + QCOMPARE(Previewer::viewerFor("application/gzip", false), QString("other")); + QCOMPARE(Previewer::viewerFor("application/zstd", false), QString("other")); + QCOMPARE(Previewer::viewerFor("application/x-sharedlib", false), QString("other")); QCOMPARE(Previewer::viewerFor("inode/directory", true), QString("folder")); } @@ -5793,6 +5802,240 @@ private slots: } } + void archiveNamesAreReadAsTheyArrive() + { + // bsdtar's output comes in parts that can end inside a line; a name + // is taken once its line is whole, decoded from bsdtar's escapes. + ArchiveNames names; + QVERIFY(names.add(".\n./\n./sub dir/\n./sub dir/caf\\303\\251 \\\\ two\\nlines.txt\n./ze")); + QVERIFY(!names.add("b")); + QVERIFY(names.add("ra.txt\n")); + QCOMPARE(names.text(false), QString::fromUtf8("At least 3 items\n\nsub dir/\nsub dir/café \\ two\\nlines.txt\nzebra.txt")); + QCOMPARE(names.text(true), QString::fromUtf8("3 items\n\nsub dir/\nsub dir/café \\ two\\nlines.txt\nzebra.txt")); + + // Past the names shown, the rest are only counted. + ArchiveNames many; + QByteArray output; + for (int i = 0; i < ArchiveNames::shown + 3; ++i) + output += QByteArray::number(i) + ".txt\n"; + QVERIFY(many.add(output)); + QVERIFY(!many.add("more.txt\n")); + const QStringList lines = many.text(true).split('\n'); + QCOMPARE(lines.size(), ArchiveNames::shown + 3); + QCOMPARE(lines.first(), QString("%1 items").arg(ArchiveNames::shown + 4)); + QCOMPARE(lines.last(), QString("4 more")); + QCOMPARE(many.text(false).section('\n', -1), QString("At least 4 more")); + + // Past the size shown too, however few the names are; a short one after a long one isn't shown out of its place. + ArchiveNames longOnes; + const QByteArray longName(int(ArchiveNames::shownBytes) * 2 / 5, 'n'); + QVERIFY(longOnes.add(longName + "1\n" + longName + "2\n")); + QVERIFY(!longOnes.add(longName + "3\nshort.txt\n")); + const QStringList few = longOnes.text(true).split('\n'); + QCOMPARE(few.size(), 5); + QCOMPARE(few.first(), QString("4 items")); + QCOMPARE(few.at(3), QString::fromLatin1(longName + "2")); + QCOMPARE(few.last(), QString("2 more")); + + // A name that comes in many parts is one name, whatever part its line ends in. + ArchiveNames parts; + QVERIFY(!parts.add("one")); + QVERIFY(!parts.add(" long")); + QVERIFY(parts.add(" name\ntwo")); + QVERIFY(parts.add("\n")); + QCOMPARE(parts.text(true), QString("2 items\n\none long name\ntwo")); + } + + void archivesListTheirNamesOffTheUiThread() + { + // The details pane and the preview window list an archive with + // bsdtar, and say so when there's nothing to list. + QTemporaryDir dir; + QDir(dir.path()).mkpath("files/sub dir"); + write(dir.filePath("files/sub dir/a b.txt"), "x"); + write(dir.filePath("files/-n.txt"), "x"); + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("-files.tar"), "-C", dir.filePath("files"), "--", "sub dir", "-n.txt"}), 0); + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("empty.tar"), "-T", "/dev/null"}), 0); + write(dir.filePath("notes.zip"), "hello\n"); + const QString listing = "3 items\n\nsub dir/\nsub dir/a b.txt\n-n.txt"; + + Backend backend(dir.path()); + auto describe = [&](const QString &name) { + backend.describe({{"path", dir.filePath(name)}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + }; + describe("-files.tar"); + QVERIFY(!backend.details().contains("text")); + QTRY_COMPARE(backend.details().value("text").toString(), listing); + describe("empty.tar"); + QTRY_COMPARE(backend.details().value("sentence").toString(), QString("Empty")); + QVERIFY(backend.details().value("text").toString().isEmpty()); + // bsdtar takes a text file for a list of names, then fails. + describe("notes.zip"); + QTRY_COMPARE(backend.details().value("sentence").toString(), QString("Couldn't read this archive.")); + QVERIFY(backend.details().value("text").toString().isEmpty()); + + Previewer previewer; + previewer.start(dir.filePath("-files.tar"), false, "name"); + QCOMPARE(previewer.viewer(), QString("archive")); + QTRY_COMPARE(previewer.text(), listing); + QVERIFY(previewer.sentence().isEmpty()); + // The commonest tarball, named by its extension as the folder lists it. + QCOMPARE(QProcess::execute("bsdtar", {"-J", "-cf", dir.filePath("-files.tar.xz"), "-C", dir.filePath("files"), "--", "sub dir", "-n.txt"}), 0); + previewer.show(dir.filePath("-files.tar.xz")); + QCOMPARE(previewer.viewer(), QString("archive")); + QTRY_COMPARE(previewer.text(), listing); + + { + const auto restore = pathAt(dir.filePath("files")); + describe("-files.tar"); + QTRY_COMPARE(backend.details().value("sentence").toString(), + QString("Listing archives needs bsdtar, from the libarchive package.")); + } + } + + void archivePreviewKeepsItsPlaceWhileNamesArrive() + { + // A stand-in bsdtar that lists half of a.tar's names, and the rest once `go` is there. + QTemporaryDir tools, dir, home; + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("a.tar"), "-T", "/dev/null"}), 0); + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("b.tar"), "-T", "/dev/null"}), 0); + QCOMPARE(QProcess::execute("bsdtar", {"-cf", dir.filePath("c.tar"), "-T", "/dev/null"}), 0); + const QByteArray go = QFile::encodeName(tools.filePath("go")); + write(tools.filePath("bsdtar"), "#!/bin/sh\nPATH=/usr/bin:/bin\ncase \"$2\" in\n" + "*/a.tar) seq -f name-%g 1 200; while [ ! -e '" + go + "' ]; do sleep 0.05; done; seq -f name-%g 201 400 ;;\n" + "*) seq -f name-%g 1 400 ;;\n" + "esac\n"); + QVERIFY(QFile::setPermissions(tools.filePath("bsdtar"), QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + const auto restore = pathAt(tools.path()); + + Theme theme(home.filePath("theme")); + Previewer previewer; + previewer.start(dir.filePath("a.tar"), 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 window(component.create()); + QVERIFY2(window, qPrintable(component.errorString())); + const auto view = [&] { return window->property("view").value(); }; + const auto place = [&] { return view()->property("contentY").toReal(); }; + + // Scrolled down while the rest is still to come: the place is kept when it comes. + QTRY_VERIFY(previewer.text().contains("name-200")); + QTRY_VERIFY(view() && view()->property("contentHeight").toReal() > view()->property("height").toReal() + 300); + QVERIFY(view()->setProperty("contentY", 300)); + write(tools.filePath("go"), "x"); + QTRY_VERIFY(previewer.text().contains("name-400")); + QCOMPARE(place(), 300.0); + + // Another file starts at its top. + previewer.next(); + QCOMPARE(previewer.item().value("name").toString(), QString("b.tar")); + QTRY_VERIFY(previewer.text().contains("name-400")); + QCOMPARE(place(), 0.0); + + // And does so while the last one still glides. + const qreal glide = 3000; + QVERIFY(view()->setProperty("contentY", 300)); + QVERIFY(QMetaObject::invokeMethod(view(), "flick", Q_ARG(qreal, 0), Q_ARG(qreal, -glide))); + QTRY_VERIFY(view()->property("moving").toBool()); + previewer.next(); + QCOMPARE(previewer.item().value("name").toString(), QString("c.tar")); + QTRY_VERIFY(previewer.text().contains("name-400")); + QTRY_VERIFY(!view()->property("moving").toBool()); + QCOMPARE(place(), 0.0); + } + + void helperKeepsOnlyTheStartOfWhatItsProgramComplainsOf() + { + // A program that fails with far more on stderr than a run keeps. + const qint64 printed = 64 * Helper::errorsKept; + Helper helper; + int ended = 0; + QByteArray errors; + helper.run("sh", {"-c", QString("echo first-line >&2; head -c %1 /dev/zero >&2; exit 3").arg(printed)}, 1024, + [](const QByteArray &) {}, [&](int code, const QByteArray &said) { ended = code; errors = said; }); + QTRY_COMPARE(ended, 3); + QCOMPARE(qint64(errors.size()), Helper::errorsKept); + QVERIFY(errors.startsWith("first-line\n")); + } + + void helperStopsReadingForWhatsNoLongerShown() + { + // A stand-in bsdtar that never finishes on stuck.tar, says the + // names are encrypted on locked.7z, and lists one name otherwise. + QTemporaryDir dir; + const QString pid = dir.filePath("pid"); + const QString kid = dir.filePath("kid"); + write(dir.filePath("bsdtar"), "#!/bin/sh\nPATH=/usr/bin:/bin\ncase \"$2\" in\n" + "*/stuck.tar) echo $$ > '" + pid.toUtf8() + "'; exec sleep 30 ;;\n" + "*/family.tar) sleep 30 & echo $! > '" + kid.toUtf8() + "'; echo $$ > '" + pid.toUtf8() + "'; wait ;;\n" + "*/locked.7z) echo 'Encrypted file is unsupported' >&2; exit 1 ;;\n" + "esac\necho one.txt\n"); + QVERIFY(QFile::setPermissions(dir.filePath("bsdtar"), QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + const auto restore = pathAt(dir.path()); + auto stuckPid = [&] { + QFile file(pid); + return file.open(QIODevice::ReadOnly) ? pid_t(file.readAll().trimmed().toInt()) : pid_t(0); + }; + + // Moving on stops it at once, and what it said is never shown. + Backend backend(dir.path()); + backend.describe({{"path", dir.filePath("stuck.tar")}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTRY_VERIFY(stuckPid() > 0); + const pid_t stuck = stuckPid(); + backend.describe({{"path", dir.filePath("other.tar")}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTRY_VERIFY_WITH_TIMEOUT(::kill(stuck, 0) != 0, 2000); + QTRY_COMPARE(backend.details().value("text").toString(), QString("1 items\n\none.txt")); + backend.describe({{"path", dir.filePath("locked.7z")}, {"kind", "archive"}, {"mime", "application/x-7z-compressed"}}); + QTRY_COMPARE(backend.details().value("sentence").toString(), QString("The names in this archive are encrypted.")); + + // What the program started (as bsdtar starts a program to unpack with) is stopped with it. + QFile::remove(pid); + backend.describe({{"path", dir.filePath("family.tar")}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTRY_VERIFY(stuckPid() > 0); + QFile started(kid); + QVERIFY(started.open(QIODevice::ReadOnly)); + const pid_t child = pid_t(started.readAll().trimmed().toInt()); + QVERIFY(child > 0 && !ended(child)); + backend.describe({{"path", dir.filePath("other.tar")}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTRY_VERIFY_WITH_TIMEOUT(ended(child), 2000); + + // A plugin's own item is no file on the disk: no program is asked about it. + QFile::remove(pid); + backend.describe({{"path", "notes:/stuck.tar"}, {"kind", "archive"}, {"mime", "application/x-tar"}}); + QTest::qWait(200); + QCOMPARE(stuckPid(), pid_t(0)); + QVERIFY(!backend.details().contains("sentence")); + + // An archive that gives no name in its time took too long: it isn't called damaged. + { + Helper slow(nullptr, 300); + QString sentence; + Previewer::listArchive(slow, dir.filePath("stuck.tar"), [&](const QString &, const QString &said) { sentence = said; }); + QTRY_COMPARE(sentence, QString("Listing this archive took too long.")); + } + + // Past its time it's stopped too, and so is one whose view goes. + QFile::remove(pid); + Helper helper(nullptr, 300); + int ended = 0; + helper.run("bsdtar", {"-tf", dir.filePath("stuck.tar")}, 1024, [](const QByteArray &) {}, + [&](int code, const QByteArray &) { ended = code; }); + QTRY_VERIFY(stuckPid() > 0); + QTRY_COMPARE(ended, Helper::stopped); + QTRY_VERIFY_WITH_TIMEOUT(::kill(stuckPid(), 0) != 0, 2000); + QFile::remove(pid); + { + Helper going; + going.run("bsdtar", {"-tf", dir.filePath("stuck.tar")}, 1024, [](const QByteArray &) {}, + [](int, const QByteArray &) {}); + QTRY_VERIFY(stuckPid() > 0); + } + QTRY_VERIFY_WITH_TIMEOUT(::kill(stuckPid(), 0) != 0, 2000); + } + void themeFallsBack() { QTemporaryDir dir; From 88a350a79ddf98727e7e37b564bbc6f1cdd5843d Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:32:18 -0400 Subject: [PATCH 2/3] Preview a font as a sample sheet A font file showed only its icon. The preview window now draws its family, style, alphabet and a line of text at five sizes in the font itself, and the details pane a short sample. Each sample is drawn from its own file and never added to Folio's fonts, so two windows on two files of one family each show their own, and a .otf file is named as a font, not as the OpenDocument template its extension alone would mean. --- README.md | 2 +- src/PreviewPane.qml | 54 +++++- src/PreviewWindow.qml | 100 +++++++++- src/backend.cpp | 5 +- src/backend.h | 3 +- src/fontsample.cpp | 336 +++++++++++++++++++++++++++++++++ src/fontsample.h | 134 ++++++++++++++ src/i18n/en.json | 6 + src/main.cpp | 6 + src/previewer.cpp | 9 +- src/previewer.h | 2 +- tests/folio_tests.cpp | 420 ++++++++++++++++++++++++++++++++++++++++++ 12 files changed, 1067 insertions(+), 10 deletions(-) create mode 100644 src/fontsample.cpp create mode 100644 src/fontsample.h diff --git a/README.md b/README.md index 3476b78c..a1c15159 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Files (`folio`) is Omarchy's file manager. It's one quiet window that follows your Omarchy theme and language, works from the keyboard, and never crawls your home folder in the background: folders are read when you open them, and thumbnails are made when a tile needs one, shared with every other app through `~/.cache/thumbnails`. - **Three views**: a grid with real previews, a list, and Finder-style columns. Ctrl+1/2/3 switches. -- **Space previews** pictures, video, audio, PDFs, text and Markdown at full size, and the names inside archives, in their own window. +- **Space previews** pictures, video, audio, PDFs, fonts, text and Markdown at full size, and the names inside archives, in their own window. - **The sidebar** holds everything in your home folder, pinned folders (shared with GTK file dialogs), Recents, Trash with restore, your drives, and the machines on your tailnet, which open over SFTP. - **Right-click** (or Alt+Enter) to open with another app, rename, duplicate, compress, share by Dropbox link, Taildrop or LocalSend, and delete. Copy and paste work with other apps, and nothing is ever overwritten. - **Split** the window in two with Ctrl+Alt+N to move things between folders. diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 397890a5..5b9256cf 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -1,6 +1,7 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import Folio import "Format.js" as Format // A larger look at the selected item and the facts about it, with the one @@ -46,6 +47,8 @@ Rectangle { // Text files show their opening lines in a frame, archives bsdtar can list the names inside. readonly property bool archive: hasItem && backend.listsArchive(item.mime) readonly property bool listed: hasItem && (item.kind === "text" || archive) + // Fonts show the alphabet and a pangram in the font itself. + readonly property bool sampled: fontSample.status !== "" // Described once the folder's loaded has put the cursor back on its // item: a re-read leaves another item on the cursor's row for a moment, // and describing that would drop what's been read of this one. @@ -71,7 +74,7 @@ Rectangle { anchors.fill: parent asynchronous: true fillMode: Image.PreserveAspectFit - source: pane.hasItem && !pane.item.isDir && !pane.listed && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" + source: pane.hasItem && !pane.item.isDir && !pane.listed && !pane.sampled && !pane.pluginItem ? Format.thumb(pane.item.path, pane.item.mime, 512, pane.item.modified) : "" visible: status === Image.Ready } @@ -102,9 +105,54 @@ Rectangle { } } + FontSample { + id: fontSample + path: pane.hasItem && !pane.item.isDir ? pane.item.path : "" + modified: pane.hasItem ? pane.item.modified : undefined + } + Rectangle { + anchors.fill: parent + visible: pane.sampled + color: theme.background + border.width: 1 + border.color: Qt.alpha(theme.foreground, 0.12) + clip: true + Column { + anchors.fill: parent + anchors.margins: 14 + spacing: 10 + visible: fontSample.status === "ready" + // The Latin alphabet and the digits on purpose: they're what the font was checked for. + FontText { + width: parent.width + source: fontSample + text: fontSample.glyphs || "ABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz\n0123456789" + color: theme.foreground + pixelSize: 18 + anywhere: true + } + FontText { + width: parent.width + visible: !fontSample.glyphs + source: fontSample + // Not translated: the font was checked for these letters and the space, no others. + text: "The quick brown fox jumps over the lazy dog" + color: theme.foreground + pixelSize: 13 + } + } + Sentence { + visible: fontSample.status === "error" + margin: 14 + text: qsTr("Couldn't read this font.") + color: theme.secondaryForeground + font.pixelSize: 12 + } + } + Glyph { anchors.centerIn: parent - visible: !big.visible && pane.hasItem && !pane.listed + visible: !big.visible && pane.hasItem && !pane.listed && !pane.sampled name: pane.pluginItem ? pane.item.icon : pane.hasItem && pane.item.isDir ? "folder" : "file" size: 72 stroke: 1.2 @@ -133,7 +181,7 @@ Rectangle { horizontalAlignment: Text.AlignLeft text: { translator.language // The type's name comes from the system, in the language of the moment. - return Format.summary(pane.item, backend.typeName(pane.item.mime || "")) + return Format.summary(pane.item, backend.typeName(pane.item.mime || "", pane.item.path || "")) } color: theme.secondaryForeground font.pixelSize: 12 diff --git a/src/PreviewWindow.qml b/src/PreviewWindow.qml index 9997fc9e..f57dd235 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`): @@ -145,6 +146,7 @@ ApplicationWindow { sourceComponent: win.viewer === "image" || win.viewer === "animated" ? imageView : win.viewer === "text" || win.viewer === "markdown" || win.viewer === "archive" ? textView : win.viewer === "folder" ? folderView + : win.viewer === "font" ? fontView : otherView } @@ -313,6 +315,102 @@ ApplicationWindow { } } + // A font as a sample sheet in the font itself, laid out and scrolled like the text view. + Component { + id: fontView + + Flickable { + id: flick + clip: true + boundsBehavior: Flickable.StopAtBounds + contentWidth: width + contentHeight: Math.max(height, sheet.implicitHeight + 72) + + function scroll(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * 60)) } + function page(direction) { contentY = Math.max(0, Math.min(contentHeight - height, contentY + direction * (height - 60))) } + + Connections { + target: previewer + function onChanged() { flick.contentY = 0 } + } + + FontSample { + id: sample + path: win.item.path || "" + modified: win.item.modified + } + + Column { + id: sheet + width: Math.min(flick.width - 96, 1200) + x: (flick.width - width) / 2 + y: 36 + spacing: 24 + visible: sample.status === "ready" + + Column { + width: parent.width + spacing: 2 + Text { + width: parent.width + text: sample.family + color: theme.brightForeground + font.pixelSize: 15 + font.weight: Font.DemiBold + elide: Text.ElideRight + } + Text { + width: parent.width + text: sample.faces > 1 ? sample.style + " · " + qsTr("%Ln faces", "", sample.faces) : sample.style + color: theme.secondaryForeground + font.pixelSize: 12 + elide: Text.ElideRight + } + } + + // The Latin alphabet and the digits on purpose: they're what the font was checked for. + FontText { + width: parent.width + source: sample + text: sample.glyphs || "ABCDEFGHIJKLMNOPQRSTUVWXYZ\nabcdefghijklmnopqrstuvwxyz\n0123456789" + color: theme.foreground + pixelSize: 36 + anywhere: true + } + + // From the text view's size up to a display size. + Repeater { + model: [13, 18, 24, 36, 48] + Column { + required property int modelData + width: sheet.width + spacing: 4 + Text { + text: qsTr("%1 px").arg(modelData) + color: theme.secondaryForeground + font.pixelSize: 12 + } + FontText { + width: parent.width + source: sample + // Not translated: the font was checked for these letters and the space, no others. + text: sample.glyphs || "The quick brown fox jumps over the lazy dog" + color: theme.foreground + pixelSize: modelData + } + } + } + } + + Sentence { + visible: sample.status === "error" + text: qsTr("Couldn't read this font.") + } + + ScrollBar.vertical: FolioScrollBar { } + } + } + // A folder: what's inside, at a glance. Component { id: folderView @@ -354,7 +452,7 @@ ApplicationWindow { } // Anything without a viewer of its own: the largest thumbnail the - // installed thumbnailers can make (HEIC, office files, fonts…), or the + // installed thumbnailers can make (HEIC, office files…), or the // file's type and a pointer to its app. Component { id: otherView diff --git a/src/backend.cpp b/src/backend.cpp index 4659c9d2..54b93ac6 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -1,5 +1,6 @@ #include "backend.h" #include "fileops.h" +#include "fontsample.h" #include "previewer.h" #include @@ -476,8 +477,8 @@ bool Backend::askPlugins() { }); } -QString Backend::typeName(const QString &mime) { - return QMimeDatabase().mimeTypeForName(mime).comment(); +QString Backend::typeName(const QString &mime, const QString &path) { + return QMimeDatabase().mimeTypeForName(FontSample::typeOf(mime, path)).comment(); } Backend::~Backend() { diff --git a/src/backend.h b/src/backend.h index 25db4cc4..aa5196b0 100644 --- a/src/backend.h +++ b/src/backend.h @@ -77,7 +77,8 @@ class Backend : public QObject { Q_INVOKABLE void describe(const QVariantMap &item, bool inTrash = false); // The system's own name for a type ("JPEG image"), in the language of // the moment. - Q_INVOKABLE static QString typeName(const QString &mime); + // `path` lets a font be named as one where its extension alone means another type. + Q_INVOKABLE static QString typeName(const QString &mime, const QString &path = {}); // Whether the details list an archive of this type's names. Q_INVOKABLE static bool listsArchive(const QString &mime) { return Previewer::listsArchive(mime); } // The item to select once the folder has loaded (where we came from, or diff --git a/src/fontsample.cpp b/src/fontsample.cpp new file mode 100644 index 00000000..91c9710a --- /dev/null +++ b/src/fontsample.cpp @@ -0,0 +1,336 @@ +#include "fontsample.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +// Past the largest CJK collections; a bigger file isn't read. +constexpr qint64 fontBytes = 128 * 1024 * 1024; +// As many characters as the alphabet in both cases and the digits. +constexpr int glyphCount = 62; +// QRawFont needs a size, though it's only asked which characters it has. +constexpr qreal probePixels = 12; +// After the space: controls and the space itself show nothing. +constexpr char32_t firstShown = 0x21; +constexpr char32_t lastCodePoint = 0x10FFFF; +// A collection's header: "ttcf", its version, how many faces, then a 4-byte offset per face. +constexpr int faceCountAt = 8; +constexpr int collectionHeader = 12; +constexpr int faceOffsetBytes = 4; + +struct Read { + QByteArray data; + QString family; + QString style; + int faces = 0; + QString glyphs; +}; + +bool showsAlone(char32_t c) { + switch (QChar::category(c)) { + case QChar::Mark_NonSpacing: + case QChar::Mark_SpacingCombining: + case QChar::Mark_Enclosing: + case QChar::Separator_Space: + case QChar::Separator_Line: + case QChar::Separator_Paragraph: + case QChar::Other_Control: + case QChar::Other_Format: + case QChar::Other_Surrogate: + case QChar::Other_NotAssigned: + return false; + default: + return true; + } +} + +// The first characters by code point the font has that `take` accepts, for as long as they're `wanted`. +QString glyphsIn(const QRawFont &font, bool (*take)(char32_t), const std::function &wanted) { + QString glyphs; + int found = 0; + for (char32_t c = firstShown; c <= lastCodePoint && found < glyphCount && wanted(); ++c) { + if (take(c) && font.supportsCharacter(c)) { + glyphs += QString::fromUcs4(&c, 1); + ++found; + } + } + return glyphs; +} + +// Runs off the UI thread: the file is read and checked here. +Read readFont(const QString &path, quint64 generation, const std::shared_ptr> &shown) { + Read read; + // Asked before the disk is, before the font is searched and at each character of that search: a font stepped past costs little. + const auto current = [&] { return shown->load() == generation; }; + if (!current()) + return read; + // Opened without waiting and read only as far as its size: a pipe or a device named like a font gives nothing and holds no thread. + const int fd = ::open(QFile::encodeName(path).constData(), O_RDONLY | O_NONBLOCK | O_CLOEXEC); + QFile file; + if (fd < 0 || !file.open(fd, QIODevice::ReadOnly, QFileDevice::AutoCloseHandle)) { + if (fd >= 0) + ::close(fd); + return read; + } + struct stat info; + if (::fstat(fd, &info) != 0 || info.st_size > fontBytes) + return read; + const QByteArray data = file.read(info.st_size); + if (!current()) + return read; + const QRawFont font(data, probePixels); + if (!font.isValid()) + return read; + read.data = data; + read.family = font.familyName(); + read.style = font.styleName(); + read.faces = FontSample::faceCount(data); + // The sheet's own characters: the alphabet in both cases and the digits. + bool sheet = true; + for (char32_t c = 'A'; c <= 'Z' && sheet; ++c) + sheet = font.supportsCharacter(c) && font.supportsCharacter(c - 'A' + 'a'); + for (char32_t c = '0'; c <= '9' && sheet; ++c) + sheet = font.supportsCharacter(c); + // The line of text has spaces between its words. + sheet = sheet && font.supportsCharacter(' '); + // Without all of them: letters it has (Greek, kana, an alphabet with no digits), else any it draws, never a fallback font's. + if (!sheet) { + read.glyphs = glyphsIn(font, [](char32_t c) { return QChar::isLetter(c); }, current); + if (read.glyphs.isEmpty()) + read.glyphs = glyphsIn(font, showsAlone, current); + // Nothing it draws alone (only marks, or nothing at all): no sample, as for a file that isn't a font. + if (read.glyphs.isEmpty()) + return {}; + } + return read; +} + +} + +FontSample::FontSample(QObject *parent) : QObject(parent) {} + +FontSample::~FontSample() { + unload(); + // A reading under way stops at its next look. + ++*m_shown; +} + +bool FontSample::isFont(const QString &mime) { + static const QStringList fonts{"font/ttf", "font/otf", "font/collection", "font/woff", "font/woff2", + "application/x-font-type1"}; + // Resolves the older names (application/x-font-ttf) to the current ones. + return fonts.contains(QMimeDatabase().mimeTypeForName(mime).name()); +} + +bool FontSample::isFontFile(const QString &path) { + return isFont(typeOf({}, path)); +} + +QString FontSample::typeOf(const QString &mime, const QString &path) { + if (isFont(mime)) + return mime; + for (const QMimeType &type : QMimeDatabase().mimeTypesForFileName(path)) + if (isFont(type.name())) + return type.name(); + return mime; +} + +// Sample: 74 74 63 66 | 00 01 00 00 | 00 00 00 02 | an offset per face is "ttcf", version 1.0, two faces. +int FontSample::faceCount(const QByteArray &data) { + if (!data.startsWith("ttcf") || data.size() < collectionHeader) + return 1; + const quint32 faces = qFromBigEndian(data.constData() + faceCountAt); + return faces > 0 && faces <= quint32(data.size() - collectionHeader) / faceOffsetBytes ? int(faces) : 1; +} + +void FontSample::setPath(const QString &path) { + if (path == m_path) + return; + m_path = path; + emit pathChanged(); + show(); +} + +void FontSample::setModified(const QVariant &modified) { + if (modified == m_modified) + return; + m_modified = modified; + emit modifiedChanged(); + // Only a font being shown is read again: the file was written over in place. + if (!m_status.isEmpty()) + show(); +} + +void FontSample::show() { + unload(); + const quint64 generation = ++*m_shown; + m_status = !m_path.isEmpty() && isFontFile(m_path) ? QStringLiteral("loading") : QString(); + emit changed(); + if (m_status.isEmpty()) + return; + // Read once the loop turns: a selection that moves sets the path and the time one after the other, and one reading serves both. + QMetaObject::invokeMethod(this, [this, generation] { + if (generation == *m_shown) + load(generation); + }, Qt::QueuedConnection); +} + +void FontSample::load(quint64 generation) { + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, generation] { + watcher->deleteLater(); + if (generation != *m_shown) + return; // Stepped on to another file while this one was read. + const Read read = watcher->result(); + if (read.data.isEmpty()) { + m_status = "error"; + emit changed(); + return; + } + // Kept, not added to Folio's fonts: Qt has one list of those, where another window's file of the same name would take this one's place. + m_data = read.data; + m_family = read.family; + m_style = read.style; + m_faces = read.faces; + m_glyphs = read.glyphs; + m_status = "ready"; + emit changed(); + }); + watcher->setFuture(QtConcurrent::run(readFont, m_path, generation, m_shown)); +} + +QRawFont FontSample::font(qreal pixelSize) const { + return m_data.isEmpty() ? QRawFont() : QRawFont(m_data, pixelSize); +} + +void FontSample::unload() { + m_data.clear(); + m_status.clear(); + m_family.clear(); + m_style.clear(); + m_faces = 0; + m_glyphs.clear(); +} + +FontText::FontText(QQuickItem *parent) : QQuickPaintedItem(parent) { + connect(this, &QQuickItem::widthChanged, this, &FontText::lay); +} + +void FontText::setSource(FontSample *source) { + if (source == m_source) + return; + if (m_source) + disconnect(m_source, &FontSample::changed, this, &FontText::load); + m_source = source; + if (m_source) + connect(m_source, &FontSample::changed, this, &FontText::load); + emit sourceChanged(); + load(); +} + +void FontText::setText(const QString &text) { + if (text == m_text) + return; + m_text = text; + emit textChanged(); + lay(); +} + +void FontText::setPixelSize(qreal pixelSize) { + if (pixelSize == m_pixelSize) + return; + m_pixelSize = pixelSize; + emit pixelSizeChanged(); + load(); +} + +void FontText::setColor(const QColor &color) { + if (color == m_color) + return; + m_color = color; + emit colorChanged(); + draw(); +} + +void FontText::setAnywhere(bool anywhere) { + if (anywhere == m_anywhere) + return; + m_anywhere = anywhere; + emit anywhereChanged(); + lay(); +} + +void FontText::load() { + m_font = m_source && m_pixelSize > 0 ? m_source->font(m_pixelSize) : QRawFont(); + lay(); +} + +void FontText::lay() { + m_runs.clear(); + qreal height = 0; + if (m_font.isValid() && width() > 0 && !m_text.isEmpty()) { + // One paragraph to the layout: a new line in the text is a line separator in it. + QTextLayout layout(QString(m_text).replace(QChar('\n'), QChar(QChar::LineSeparator))); + // Shaped with this font alone, so a character it lacks is its own missing glyph, never another font's. + layout.setRawFont(m_font); + QTextOption option; + option.setWrapMode(m_anywhere ? QTextOption::WrapAnywhere : QTextOption::WrapAtWordBoundaryOrAnywhere); + layout.setTextOption(option); + layout.beginLayout(); + for (QTextLine line = layout.createLine(); line.isValid(); line = layout.createLine()) { + line.setLineWidth(width()); + line.setPosition({0, height}); + height += line.height(); + } + layout.endLayout(); + m_runs = layout.glyphRuns(); + } + setImplicitHeight(std::ceil(height)); + draw(); +} + +void FontText::draw() { + // A QRawFont and the glyph runs made with it belong to this thread, so they're drawn here and never in paint(). + const qreal ratio = window() ? window()->effectiveDevicePixelRatio() : qGuiApp->devicePixelRatio(); + const QSize size = (QSizeF(width(), implicitHeight()) * ratio).toSize(); + m_picture = QImage(); + if (!m_runs.isEmpty() && !size.isEmpty()) { + m_picture = QImage(size, QImage::Format_ARGB32_Premultiplied); + m_picture.setDevicePixelRatio(ratio); + m_picture.fill(Qt::transparent); + QPainter painter(&m_picture); + painter.setPen(m_color); + painter.setRenderHint(QPainter::TextAntialiasing); + for (const QGlyphRun &run : std::as_const(m_runs)) + painter.drawGlyphRun({}, run); + } + update(); +} + +void FontText::itemChange(ItemChange change, const ItemChangeData &value) { + // A window of another scale needs a picture of its own. + if (change == ItemDevicePixelRatioHasChanged || change == ItemSceneChange) + draw(); + QQuickPaintedItem::itemChange(change, value); +} + +void FontText::paint(QPainter *painter) { + painter->drawImage(QPointF(0, 0), m_picture); +} diff --git a/src/fontsample.h b/src/fontsample.h new file mode 100644 index 00000000..959ee63b --- /dev/null +++ b/src/fontsample.h @@ -0,0 +1,134 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// A font file as a sample sheet: read off the UI thread and drawn from its own bytes, never added to Folio's fonts. +class FontSample : public QObject { + Q_OBJECT + Q_PROPERTY(QString path READ path WRITE setPath NOTIFY pathChanged) + // The file's modified time, or anything that tells one writing of it from the next: a change reads it again. + Q_PROPERTY(QVariant modified READ modified WRITE setModified NOTIFY modifiedChanged) + // Empty for a file that isn't a font, then loading, ready or error. + Q_PROPERTY(QString status READ status NOTIFY changed) + // The family's name and its first face's style, as the file gives them. + Q_PROPERTY(QString family READ family NOTIFY changed) + Q_PROPERTY(QString style READ style NOTIFY changed) + Q_PROPERTY(int faces READ faces NOTIFY changed) + // Characters it has, drawn in place of the alphabet, digits and pangram; empty when it has all of those. + Q_PROPERTY(QString glyphs READ glyphs NOTIFY changed) + +public: + explicit FontSample(QObject *parent = nullptr); + ~FontSample() override; + + QString path() const { return m_path; } + void setPath(const QString &path); + QVariant modified() const { return m_modified; } + void setModified(const QVariant &modified); + QString status() const { return m_status; } + QString family() const { return m_family; } + QString style() const { return m_style; } + int faces() const { return m_faces; } + QString glyphs() const { return m_glyphs; } + // The file's first face at a size; not valid unless ready. + QRawFont font(qreal pixelSize) const; + + // TrueType, OpenType, collections, WOFF and Type 1, by their current or older names. + static bool isFont(const QString &mime); + // Whether a name could be a font's; by extension alone ".otf" is an OpenDocument template. + static bool isFontFile(const QString &path); + // The font type a file's name could mean, or `mime` when it's one already or the name means none. + static QString typeOf(const QString &mime, const QString &path); + // The faces in a font collection; 1 for a single font. + static int faceCount(const QByteArray &data); + +signals: + void pathChanged(); + void modifiedChanged(); + void changed(); + +private: + void show(); + void load(quint64 generation); + void unload(); + + QString m_path; + QVariant m_modified; + QString m_status; + QString m_family; + QString m_style; + int m_faces = 0; + QString m_glyphs; + // The file's bytes while it's shown. + QByteArray m_data; + // Counts what was asked for; a reading goes on, and is kept, only while it's still the newest. Shared with the readings under way. + std::shared_ptr> m_shown = std::make_shared>(0); +}; + +// Text drawn in a sample's own font: two files of one family name each draw as themselves. +class FontText : public QQuickPaintedItem { + Q_OBJECT + Q_PROPERTY(FontSample *source READ source WRITE setSource NOTIFY sourceChanged) + Q_PROPERTY(QString text READ text WRITE setText NOTIFY textChanged) + Q_PROPERTY(qreal pixelSize READ pixelSize WRITE setPixelSize NOTIFY pixelSizeChanged) + Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged) + // Breaks a line at any character, for a sheet of characters; otherwise between words where it can. + Q_PROPERTY(bool anywhere READ anywhere WRITE setAnywhere NOTIFY anywhereChanged) + +public: + explicit FontText(QQuickItem *parent = nullptr); + + FontSample *source() const { return m_source; } + void setSource(FontSample *source); + QString text() const { return m_text; } + void setText(const QString &text); + qreal pixelSize() const { return m_pixelSize; } + void setPixelSize(qreal pixelSize); + QColor color() const { return m_color; } + void setColor(const QColor &color); + bool anywhere() const { return m_anywhere; } + void setAnywhere(bool anywhere); + + // What's drawn: the glyphs of the source's own font, line by line. + QList runs() const { return m_runs; } + // Those glyphs as a picture, made on the thread that owns the font: paint() runs on the render thread and only copies it. + QImage picture() const { return m_picture; } + void paint(QPainter *painter) override; + +signals: + void sourceChanged(); + void textChanged(); + void pixelSizeChanged(); + void colorChanged(); + void anywhereChanged(); + +protected: + void itemChange(ItemChange change, const ItemChangeData &value) override; + +private: + void load(); + void lay(); + void draw(); + + QPointer m_source; + QString m_text; + qreal m_pixelSize = 13; + QColor m_color; + bool m_anywhere = false; + // Loaded once for a source and a size: a window being resized only lays the lines again. + QRawFont m_font; + QList m_runs; + QImage m_picture; +}; diff --git a/src/i18n/en.json b/src/i18n/en.json index f81a0251..b1531f26 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -8,6 +8,7 @@ "%1 GB": "%1 GB", "%1 KB": "%1 KB", "%1 MB": "%1 MB", + "%1 px": "%1 px", "%1 TB": "%1 TB", "%1 already exists here.": "%1 already exists here.", "%1 can't be burned onto that drive.": "%1 can't be burned onto that drive.", @@ -32,6 +33,10 @@ "%1 · %2": "%1 · %2", "%1 × %2": "%1 × %2", "%1: %2": "%1: %2", + "%Ln faces": { + "one": "%Ln face", + "other": "%Ln faces" + }, "%Ln items": { "one": "%Ln item", "other": "%Ln items" @@ -131,6 +136,7 @@ "Couldn't play this file: %1": "Couldn't play this file: %1", "Couldn't read %1.": "Couldn't read %1.", "Couldn't read this archive.": "Couldn't read this archive.", + "Couldn't read this font.": "Couldn't read this font.", "Couldn't read this picture.": "Couldn't read this picture.", "Couldn't recreate %1.": "Couldn't recreate %1.", "Couldn't remove %1.": "Couldn't remove %1.", diff --git a/src/main.cpp b/src/main.cpp index 9228dca5..2d5dda4a 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,6 +13,7 @@ #include #include "filemanager1.h" +#include "fontsample.h" #include "foldermodel.h" #include "places.h" #include "plugins.h" @@ -104,6 +105,8 @@ static int preview(QGuiApplication &app) { QObject::connect(&translator, &Translator::changed, &engine, &QQmlEngine::retranslate); QObject::connect(&translator, &Translator::changed, &previewer, &Previewer::relist); engine.addImageProvider("thumb", new ThumbnailProvider(&thumbnails)); + qmlRegisterType("Folio", 1, 0, "FontSample"); + qmlRegisterType("Folio", 1, 0, "FontText"); engine.rootContext()->setContextProperty("translator", &translator); engine.rootContext()->setContextProperty("theme", &theme); engine.rootContext()->setContextProperty("previewer", &previewer); @@ -144,6 +147,9 @@ int main(int argc, char *argv[]) { engine.addImageProvider("plugin-icon", new PluginIconProvider); // Creatable too: the column view lists each folder on the way here. qmlRegisterType("Folio", 1, 0, "FolderModel"); + // The details pane's sample of a font, and the text drawn in it. + qmlRegisterType("Folio", 1, 0, "FontSample"); + qmlRegisterType("Folio", 1, 0, "FontText"); engine.rootContext()->setContextProperty("translator", &translator); engine.rootContext()->setContextProperty("theme", &theme); engine.rootContext()->setContextProperty("places", &places); diff --git a/src/previewer.cpp b/src/previewer.cpp index b36110b9..5499162f 100644 --- a/src/previewer.cpp +++ b/src/previewer.cpp @@ -1,4 +1,5 @@ #include "previewer.h" +#include "fontsample.h" #include "fileops.h" #include @@ -22,6 +23,8 @@ QString Previewer::viewerFor(const QString &mime, bool isDir) { return "folder"; if (mime == "application/pdf") return "pdf"; + if (FontSample::isFont(mime)) + return "font"; if (mime.startsWith("video/")) return "video"; if (mime.startsWith("audio/")) @@ -202,7 +205,8 @@ QString Previewer::url() const { QString Previewer::type() const { if (m_index < 0 || m_index >= m_entries.size()) return {}; - return QMimeDatabase().mimeTypeForName(m_entries.at(m_index).mime).comment(); + const FolderModel::Entry &e = m_entries.at(m_index); + return QMimeDatabase().mimeTypeForName(FontSample::typeOf(e.mime, e.path)).comment(); } void Previewer::next() { @@ -227,6 +231,9 @@ void Previewer::setIndex(int index, bool report) { m_index = index; const FolderModel::Entry e = m_entries.at(index); m_viewer = viewerFor(e.mime, e.isDir); + // By its extension alone a ".otf" font is taken for an OpenDocument template. + if (m_viewer == "other" && FontSample::isFontFile(e.path)) + m_viewer = "font"; m_text.clear(); m_sentence.clear(); m_helper.stop(); diff --git a/src/previewer.h b/src/previewer.h index 3eb9788c..62bf3825 100644 --- a/src/previewer.h +++ b/src/previewer.h @@ -112,7 +112,7 @@ class Previewer : public QObject { Q_INVOKABLE void open(); // Which view shows a type: image, animated, thumbnail, video, audio, - // pdf, markdown, text, archive, folder or other. + // pdf, font, markdown, text, archive, folder or other. static QString viewerFor(const QString &mime, bool isDir); // Whether `bsdtar -tf` lists an archive of this type; a lone .gz, .xz or .zst isn't one. static bool listsArchive(const QString &mime); diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index b06942b9..3b3ef9f4 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -1,6 +1,8 @@ #include +#include #include #include +#include #include #include #include @@ -22,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -32,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +44,7 @@ #include "disks.h" #include "fileops.h" #include "foldermodel.h" +#include "fontsample.h" #include "mounter.h" #include "places.h" #include "plugins.h" @@ -5912,6 +5917,8 @@ private slots: Previewer previewer; previewer.start(dir.filePath("a.tar"), false, "name"); QQmlEngine engine; + qmlRegisterType("Folio", 1, 0, "FontSample"); + qmlRegisterType("Folio", 1, 0, "FontText"); engine.rootContext()->setContextProperty("theme", &theme); engine.rootContext()->setContextProperty("previewer", &previewer); engine.rootContext()->setContextProperty("translator", QVariantMap{{"language", "en"}}); @@ -6036,6 +6043,317 @@ private slots: QTRY_VERIFY_WITH_TIMEOUT(::kill(stuckPid(), 0) != 0, 2000); } + void previewerShowsFontsAsASampleSheet() + { + for (const char *mime : {"font/ttf", "font/otf", "font/collection", "font/woff", "font/woff2", + "application/x-font-ttf", "application/x-font-otf", "application/x-font-type1"}) + QCOMPARE(Previewer::viewerFor(mime, false), QString("font")); + // A bitmap font has no outlines to draw a sheet in. + QCOMPARE(Previewer::viewerFor("application/x-font-bdf", false), QString("other")); + + // By its extension alone a ".otf" is an OpenDocument template; it's still shown as a font. + QTemporaryDir dir; + write(dir.filePath("Sample.otf"), tinyFont("Folio Sample", "ABC")); + Previewer previewer; + previewer.start(dir.filePath("Sample.otf"), false, "name"); + QCOMPARE(previewer.viewer(), QString("font")); + + // And named as one, in the preview window and in the details pane. + const QMimeDatabase mimes; + const QString byName = mimes.mimeTypeForFile(dir.filePath("Sample.otf"), QMimeDatabase::MatchExtension).name(); + const QString font = mimes.mimeTypeForName("font/otf").comment(); + QVERIFY(!font.isEmpty() && mimes.mimeTypeForName(byName).comment() != font); + QCOMPARE(previewer.type(), font); + QCOMPARE(Backend::typeName(byName, dir.filePath("Sample.otf")), font); + QCOMPARE(Backend::typeName("text/plain", dir.filePath("notes.txt")), mimes.mimeTypeForName("text/plain").comment()); + } + + void fontSampleKeepsItsFontToItself() + { + QTemporaryDir dir; + const QString latin = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + write(dir.filePath("One.ttf"), tinyFont("Folio One", latin)); + write(dir.filePath("Two.ttf"), tinyFont("Folio Two", latin)); + write(dir.filePath("notes.txt"), "x"); + const QStringList before = QFontDatabase::families(); + + auto sample = std::make_unique(); + sample->setPath(dir.filePath("One.ttf")); + QCOMPARE(sample->status(), QString("loading")); + QTRY_COMPARE(sample->status(), QString("ready")); + QCOMPARE(sample->family(), QString("Folio One")); + QCOMPARE(sample->style(), QString("Regular")); + QCOMPARE(sample->faces(), 1); + QVERIFY(sample->glyphs().isEmpty()); + // The sample has the font; Folio's own list of fonts never does. + QCOMPARE(sample->font(12).familyName(), QString("Folio One")); + QCOMPARE(QFontDatabase::families(), before); + + // Stepping to another font swaps one for the other, and to a file that isn't one leaves neither. + sample->setPath(dir.filePath("Two.ttf")); + QVERIFY(!sample->font(12).isValid()); + QTRY_COMPARE(sample->status(), QString("ready")); + QCOMPARE(sample->font(12).familyName(), QString("Folio Two")); + QCOMPARE(QFontDatabase::families(), before); + sample->setPath(dir.filePath("notes.txt")); + QVERIFY(sample->status().isEmpty()); + QVERIFY(!sample->font(12).isValid()); + + // A font stepped past while it's read is never the one kept. + sample->setPath(dir.filePath("One.ttf")); + sample->setPath(dir.filePath("Two.ttf")); + QTRY_COMPARE(sample->status(), QString("ready")); + QCOMPARE(sample->family(), QString("Folio Two")); + QCOMPARE(sample->font(12).familyName(), QString("Folio Two")); + QCOMPARE(QFontDatabase::families(), before); + } + + void fontSampleDrawsOnlyWhatTheFontHas() + { + QTemporaryDir dir; + // Greek letters and a star but no Latin: the letters are drawn, never + // the alphabet in boxes or another font. Without letters, the star. + write(dir.filePath("Greek.ttf"), tinyFont("Folio Greek", QString::fromUtf8("★αγβ"))); + write(dir.filePath("Stars.ttf"), tinyFont("Folio Stars", QString::fromUtf8("★☆"))); + // The alphabet without the digits: its letters are drawn, never digits from another font. + const QString letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + write(dir.filePath("Letters.ttf"), tinyFont("Folio Letters", letters)); + write(dir.filePath("Pair.ttc"), tinyCollection({"Folio Left", "Folio Right"})); + write(dir.filePath("Broken.ttf"), "not a font"); + write(dir.filePath("Empty.woff2"), ""); + const QStringList before = QFontDatabase::families(); + FontSample sample; + sample.setPath(dir.filePath("Greek.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.glyphs(), QString::fromUtf8("αβγ")); + sample.setPath(dir.filePath("Stars.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.glyphs(), QString::fromUtf8("★☆")); + sample.setPath(dir.filePath("Letters.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.glyphs(), letters); + // The alphabet and the digits without the space: its letters again, never a line of words with boxes between them. + write(dir.filePath("Tight.ttf"), tinyFont("Folio Tight", letters + "0123456789")); + sample.setPath(dir.filePath("Tight.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.glyphs(), letters); + + // A collection shows its first face and counts them all. + sample.setPath(dir.filePath("Pair.ttc")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.family(), QString("Folio Left")); + QCOMPARE(sample.faces(), 2); + + // A file that can't be read as a font adds nothing. + sample.setPath(dir.filePath("Broken.ttf")); + QTRY_COMPARE(sample.status(), QString("error")); + QVERIFY(sample.family().isEmpty()); + sample.setPath(dir.filePath("Empty.woff2")); + QTRY_COMPARE(sample.status(), QString("error")); + + // Only a mark that sits on another character: nothing to draw alone, so no sheet from another font. + write(dir.filePath("Marks.ttf"), tinyFont("Folio Marks", QString(QChar(0x0301)))); + sample.setPath(dir.filePath("Marks.ttf")); + QTRY_COMPARE(sample.status(), QString("error")); + + // A pipe named like a font, with nobody writing to it, is not waited on. + const QByteArray pipe = QFile::encodeName(dir.filePath("Pipe.ttf")); + QCOMPARE(::mkfifo(pipe.constData(), 0600), 0); + // Lets go of a reader that did wait, so a failure here ends the suite. + const auto release = qScopeGuard([&] { ::close(::open(pipe.constData(), O_WRONLY | O_NONBLOCK)); }); + sample.setPath(dir.filePath("Pipe.ttf")); + QTRY_COMPARE(sample.status(), QString("error")); + QCOMPARE(QFontDatabase::families(), before); + } + + void fontSamplesOfOneNameEachDrawTheirOwnFile() + { + // Two files of one family and style, shown at once as two windows show them, and a font of that name among Folio's own. + QTemporaryDir dir; + const int there = QFontDatabase::addApplicationFontFromData(tinyFont("Folio Twin", "C")); + QVERIFY(there >= 0); + const auto remove = qScopeGuard([&] { QFontDatabase::removeApplicationFont(there); }); + write(dir.filePath("First.ttf"), tinyFont("Folio Twin", "A")); + write(dir.filePath("Second.ttf"), tinyFont("Folio Twin", "B")); + FontSample first, second; + FontText one, two; + // The glyph drawn for each character of "AB": 0 is the font's own missing glyph. + const auto drawn = [](const FontText &text) { + QList glyphs; + for (const QGlyphRun &run : text.runs()) + glyphs += run.glyphIndexes(); + return glyphs; + }; + for (FontText *text : {&one, &two}) { + text->setWidth(400); + text->setPixelSize(18); + text->setText("AB"); + } + one.setSource(&first); + two.setSource(&second); + QVERIFY(drawn(one).isEmpty()); + first.setPath(dir.filePath("First.ttf")); + QTRY_COMPARE(first.status(), QString("ready")); + second.setPath(dir.filePath("Second.ttf")); + QTRY_COMPARE(second.status(), QString("ready")); + QCOMPARE(first.family(), second.family()); + QCOMPARE(first.style(), second.style()); + QCOMPARE(drawn(one), QList({1, 0})); + QCOMPARE(drawn(two), QList({0, 1})); + QVERIFY(one.implicitHeight() > 0); + // Folio's own font of that name is still the one its name draws. + const QRawFont named = QRawFont::fromFont(QFont("Folio Twin")); + QVERIFY(named.supportsCharacter(QChar('C')) && !named.supportsCharacter(QChar('A')) && !named.supportsCharacter(QChar('B'))); + + // Lines break at the width, a new line in the text starts one, and nothing is drawn once the font goes. + two.setAnywhere(true); + two.setText("BBBB\nB"); + const qreal twoLines = two.implicitHeight(); + QVERIFY(twoLines > one.implicitHeight()); + two.setWidth(two.pixelSize()); + QVERIFY(two.implicitHeight() > twoLines); + QCOMPARE(drawn(two).size(), 5); + second.setPath({}); + QVERIFY(drawn(two).isEmpty()); + QCOMPARE(two.implicitHeight(), 0.0); + QCOMPARE(drawn(one), QList({1, 0})); + } + + void fontTextPaintsAPictureMadeWithItsFont() + { + // paint() runs on the render thread, where a font made on this one must not be used: it only copies a picture. + QTemporaryDir dir; + write(dir.filePath("Inked.ttf"), tinyFont("Folio Inked", "A", 0, true)); + FontSample sample; + FontText text; + text.setWidth(200); + text.setPixelSize(40); + text.setColor(Qt::red); + text.setText("A"); + text.setSource(&sample); + QVERIFY(text.picture().isNull()); + sample.setPath(dir.filePath("Inked.ttf")); + QTRY_COMPARE(sample.status(), QString("ready")); + // How many pixels are wholly ink of one colour: the square's inside. + const auto inked = [](const QImage &picture, const QColor &colour) { + int pixels = 0; + for (int y = 0; y < picture.height(); ++y) + for (int x = 0; x < picture.width(); ++x) + pixels += picture.pixelColor(x, y) == colour; + return pixels; + }; + const QImage red = text.picture(); + QCOMPARE(red.size(), (QSizeF(200, text.implicitHeight()) * red.devicePixelRatio()).toSize()); + QVERIFY(inked(red, Qt::red) > 100); + QCOMPARE(inked(red, Qt::blue), 0); + // What paint() puts down is that picture. + QImage painted(red.size(), red.format()); + painted.setDevicePixelRatio(red.devicePixelRatio()); + painted.fill(Qt::transparent); + { + QPainter painter(&painted); + text.paint(&painter); + } + QCOMPARE(painted, red); + // A new colour makes a new picture, and no font leaves none. + text.setColor(Qt::blue); + QVERIFY(inked(text.picture(), Qt::blue) > 100); + QCOMPARE(inked(text.picture(), Qt::red), 0); + sample.setPath({}); + QVERIFY(text.picture().isNull()); + } + + void fontSampleReadsAFontAgainWhenItIsWrittenOver() + { + QTemporaryDir dir; + const QString latin = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const QString path = dir.filePath("One.ttf"); + const QDateTime written = QDateTime::currentDateTime(); + write(path, tinyFont("Folio Before", latin)); + FontSample sample; + sample.setPath(path); + sample.setModified(written); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.family(), QString("Folio Before")); + + // Written over in place: the path is the same, the time the folder reports is not. + write(path, tinyFont("Folio After", latin)); + sample.setModified(written.addSecs(1)); + QCOMPARE(sample.status(), QString("loading")); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.family(), QString("Folio After")); + QCOMPARE(sample.font(12).familyName(), QString("Folio After")); + + // The same time again reads nothing, and neither does a time for a file that isn't a font. + sample.setModified(written.addSecs(1)); + QCOMPARE(sample.status(), QString("ready")); + write(dir.filePath("notes.txt"), "x"); + sample.setPath(dir.filePath("notes.txt")); + sample.setModified(written.addSecs(2)); + QVERIFY(sample.status().isEmpty()); + } + + void fontSampleReadsOnlyWhatIsStillShown() + { + QTemporaryDir dir; + const QString latin = " ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + write(dir.filePath("One.ttf"), tinyFont("Folio One", latin)); + write(dir.filePath("Two.ttf"), tinyFont("Folio Two", latin)); + // Counts how often each font is opened from here on. + const int watch = ::inotify_init1(IN_NONBLOCK | IN_CLOEXEC); + QVERIFY(watch >= 0); + const auto closed = qScopeGuard([watch] { ::close(watch); }); + const int one = ::inotify_add_watch(watch, QFile::encodeName(dir.filePath("One.ttf")).constData(), IN_OPEN); + const int two = ::inotify_add_watch(watch, QFile::encodeName(dir.filePath("Two.ttf")).constData(), IN_OPEN); + QVERIFY(one >= 0 && two >= 0); + QHash opened; + const auto count = [&] { + char events[4096]; + for (ssize_t got; (got = ::read(watch, events, sizeof events)) > 0;) { + for (ssize_t at = 0; at < got;) { + inotify_event event; + memcpy(&event, events + at, sizeof event); + ++opened[event.wd]; + at += ssize_t(sizeof event) + event.len; + } + } + }; + + // A selection that moves sets the path, then the time: the font is opened once for both. + static constexpr int aReadingStartsMs = 100; + FontSample sample; + sample.setPath(dir.filePath("One.ttf")); + QThread::msleep(aReadingStartsMs); + // Counted here too: two opens that nobody read between come as one event. + count(); + sample.setModified(QDateTime::currentDateTime()); + QTRY_COMPARE(sample.status(), QString("ready")); + count(); + QCOMPARE(opened.value(one), 1); + + // With every worker busy, a font stepped past before its turn comes is never opened. + QThreadPool *pool = QThreadPool::globalInstance(); + const int workers = pool->maxThreadCount(); + QSemaphore busy; + pool->setMaxThreadCount(1); + const auto freed = qScopeGuard([&] { + busy.release(); + pool->setMaxThreadCount(workers); + }); + QFuture holder = QtConcurrent::run([&busy] { busy.acquire(); }); + sample.setPath(dir.filePath("Two.ttf")); + QCoreApplication::processEvents(); + sample.setPath(dir.filePath("One.ttf")); + QCoreApplication::processEvents(); + busy.release(); + QTRY_COMPARE(sample.status(), QString("ready")); + QCOMPARE(sample.family(), QString("Folio One")); + holder.waitForFinished(); + count(); + QCOMPARE(opened.value(two), 0); + QCOMPARE(opened.value(one), 2); + } + void themeFallsBack() { QTemporaryDir dir; @@ -6323,6 +6641,108 @@ private slots: return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); } + // A TrueType font for `chars`, its glyphs empty unless `inked`, starting `at` bytes into its file: enough for FreeType to load. + static QByteArray tinyFont(const QString &family, const QString &chars, quint32 at = 0, bool inked = false) + { + QList codes; + for (const QChar c : chars) + codes << c.unicode(); + std::sort(codes.begin(), codes.end()); + const quint16 glyphs = quint16(codes.size() + 1); + const quint16 segments = quint16(codes.size() + 1); + auto table = [](const std::function &fill) { + QByteArray bytes; + QDataStream s(&bytes, QIODevice::WriteOnly); + fill(s); + return bytes; + }; + auto zeros = [](QDataStream &s, int n) { for (int i = 0; i < n; ++i) s << quint16(0); }; + // By tag, in the sorted order the table directory wants. + QMap tables; + tables["cmap"] = table([&](QDataStream &s) { + s << quint16(0) << quint16(1) << quint16(3) << quint16(1) << quint32(12); + s << quint16(4) << quint16(16 + 8 * segments) << quint16(0) << quint16(2 * segments); + zeros(s, 3); + for (const quint16 c : codes) s << c; + s << quint16(0xFFFF) << quint16(0); + for (const quint16 c : codes) s << c; + s << quint16(0xFFFF); + for (int i = 0; i < codes.size(); ++i) s << quint16(i + 1 - codes[i]); + s << quint16(1); + zeros(s, segments); + }); + // When `inked`, the first character is a square: one contour of 4 points on the curve, each a 16-bit step. + tables["glyf"] = inked ? table([](QDataStream &s) { + s << qint16(1) << qint16(100) << qint16(0) << qint16(500) << qint16(700) << quint16(3) << quint16(0); + for (int i = 0; i < 4; ++i) s << quint8(1); + s << qint16(100) << qint16(400) << qint16(0) << qint16(-400) << qint16(0) << qint16(0) << qint16(700) << qint16(0); + }) : QByteArray(4, '\0'); + tables["head"] = table([](QDataStream &s) { + s << quint32(0x10000) << quint32(0x10000) << quint32(0) << quint32(0x5F0F3CF5) << quint16(0xB) + << quint16(1000) << quint64(0) << quint64(0) << qint16(0) << qint16(-200) << qint16(600) + << qint16(800) << quint16(0) << quint16(8) << qint16(2) << qint16(0) << qint16(0); + }); + tables["hhea"] = table([&](QDataStream &s) { + s << quint32(0x10000) << qint16(800) << qint16(-200) << qint16(0) << quint16(600); + zeros(s, 3); + s << qint16(1); + zeros(s, 7); + s << glyphs; + }); + tables["hmtx"] = table([&](QDataStream &s) { for (int i = 0; i < glyphs; ++i) s << quint16(600) << qint16(0); }); + // In 2-byte units: the square's 34 bytes are glyph 1, and every other glyph is empty. + tables["loca"] = table([&](QDataStream &s) { + zeros(s, 2); + for (int i = 2; i <= glyphs; ++i) s << quint16(inked ? 17 : 0); + }); + tables["maxp"] = table([&](QDataStream &s) { + s << quint32(0x10000) << glyphs << quint16(inked ? 4 : 0) << quint16(inked ? 1 : 0); + zeros(s, 2); + s << quint16(2); + zeros(s, 8); + }); + const QStringList names{family, "Regular", family + " Regular", QString(family).remove(' ')}; + tables["name"] = table([&](QDataStream &s) { + s << quint16(0) << quint16(names.size()) << quint16(6 + 12 * names.size()); + const QList ids{1, 2, 4, 6}; + int offset = 0; + for (int i = 0; i < names.size(); ++i) { + s << quint16(3) << quint16(1) << quint16(0x409) << ids[i] << quint16(2 * names[i].size()) << quint16(offset); + offset += 2 * names[i].size(); + } + for (const QString &name : names) + for (const QChar c : name) s << quint16(c.unicode()); + }); + + QByteArray font = table([&](QDataStream &s) { + s << quint32(0x10000) << quint16(tables.size()); + zeros(s, 3); + }); + QByteArray body; + const quint32 start = at + 12 + 16 * tables.size(); + for (auto it = tables.cbegin(); it != tables.cend(); ++it) { + font += it.key(); + font += table([&](QDataStream &s) { s << quint32(0) << quint32(start + body.size()) << quint32(it.value().size()); }); + body += it.value() + QByteArray((4 - it.value().size() % 4) % 4, '\0'); + } + return font + body; + } + + // A collection of one tiny font per family, each face after the last. + static QByteArray tinyCollection(const QStringList &families) + { + const quint32 header = 12 + 4 * families.size(); + QByteArray faces; + QByteArray collection("ttcf"); + QDataStream s(&collection, QIODevice::Append); + s << quint32(0x10000) << quint32(families.size()); + for (const QString &family : families) { + s << quint32(header + faces.size()); + faces += tinyFont(family, "AB", header + faces.size()); + } + return collection + faces; + } + static void write(const QString &path, const QByteArray &contents) { QFile file(path); From 217871226345ef924183414ddbd387fe5c422d73 Mon Sep 17 00:00:00 2001 From: GM Date: Wed, 11 Nov 2026 01:32:18 -0400 Subject: [PATCH 3/3] Show a video's or a song's length and codecs The details pane gave a video or a sound file only its size and date. It now adds Length, Video and Audio rows, and a video's dimensions, read by ffprobe for the item shown. Without ffprobe it names the package that holds it. --- src/Format.js | 14 ++++++- src/PreviewPane.qml | 39 ++++++++++++++++++ src/backend.cpp | 78 +++++++++++++++++++++++++++++++++++- src/backend.h | 8 ++-- src/i18n/en.json | 4 ++ tests/folio_tests.cpp | 92 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 5 deletions(-) diff --git a/src/Format.js b/src/Format.js index a359f60e..fa9858c0 100644 --- a/src/Format.js +++ b/src/Format.js @@ -1,7 +1,7 @@ .pragma library .import QtQml as QtQml -// Sizes, dates, counts and thumbnail URLs as the views show them, in one +// Sizes, dates, lengths, counts and thumbnail URLs as the views show them, in one // copy that every tile and row shares. A library can't see context // properties, so a binding that shows a date reads translator.language // itself to follow a language switch; whatever goes through qsTr here is @@ -33,6 +33,18 @@ function when(date) { return today ? date.toLocaleTimeString(locale, QtQml.Locale.ShortFormat) : date.toLocaleDateString(locale, QtQml.Locale.ShortFormat) } +// A length as a player's clock (1:05, or 1:02:05 past the hour); nothing when unknown. +function length(seconds) { + const perMinute = 60, perHour = 3600 + if (!(seconds > 0)) + return "" + const total = Math.floor(seconds) + const hours = Math.floor(total / perHour) + const minutes = Math.floor(total % perHour / perMinute) + const rest = String(total % perMinute).padStart(2, "0") + return hours > 0 ? hours + ":" + String(minutes).padStart(2, "0") + ":" + rest : minutes + ":" + rest +} + // A folder's contents; nothing until they've been counted. function count(n) { return n === 0 ? qsTr("No items") : n > 0 ? qsTr("%Ln items", "", n) : "" diff --git a/src/PreviewPane.qml b/src/PreviewPane.qml index 5b9256cf..797f7907 100644 --- a/src/PreviewPane.qml +++ b/src/PreviewPane.qml @@ -278,6 +278,45 @@ Rectangle { color: theme.foreground font.pixelSize: 12 } + Text { visible: !!pane.info.seconds; text: qsTr("Length"); color: theme.secondaryForeground; font.pixelSize: 12 } + Text { + visible: !!pane.info.seconds + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: Format.length(pane.info.seconds) + color: theme.foreground + font.pixelSize: 12 + } + Text { visible: !!pane.info.video; text: qsTr("Video"); color: theme.secondaryForeground; font.pixelSize: 12 } + Text { + visible: !!pane.info.video + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: pane.info.video || "" + color: theme.foreground + font.pixelSize: 12 + elide: Text.ElideRight + } + Text { visible: !!pane.info.audio; text: qsTr("Audio"); color: theme.secondaryForeground; font.pixelSize: 12 } + Text { + visible: !!pane.info.audio + Layout.fillWidth: true + horizontalAlignment: Text.AlignRight + text: pane.info.audio || "" + color: theme.foreground + font.pixelSize: 12 + elide: Text.ElideRight + } + // Without ffprobe, the package that brings it. + Text { + visible: (pane.item.kind === "video" || pane.item.kind === "audio") && !!pane.info.sentence + Layout.columnSpan: 2 + Layout.fillWidth: true + text: pane.info.sentence || "" + color: theme.secondaryForeground + font.pixelSize: 12 + wrapMode: Text.Wrap + } // Plugins' rows, label and value in turn, after Folio's own: a // plugin's item's details, or what plugins say about a file. Repeater { diff --git a/src/backend.cpp b/src/backend.cpp index 54b93ac6..ed5d6c30 100644 --- a/src/backend.cpp +++ b/src/backend.cpp @@ -9,14 +9,22 @@ #include #include #include +#include #include +#include +#include +#include #include #include #include #include #include +#include #include +// ffprobe's answer for a file with many streams and tags is a few kilobytes. +static constexpr qint64 probeOutputLimit = 1024 * 1024; + Backend::Backend(const QString &home, QObject *parent) : QObject(parent), m_home(home.isEmpty() ? QDir::homePath() : home) { m_noticeTimer.setSingleShot(true); @@ -380,7 +388,7 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { m_pluginItem = path.startsWith('/') && !inTrash ? item : QVariantMap(); showPluginRows(askPlugins()); // What a helper program reads comes on top of the rest. - const auto help = [this, path, mime](const QVariantMap &details) { + const auto help = [this, path, kind, mime](const QVariantMap &details) { // A helper program reads a file on the disk: a plugin's own item isn't one. if (!path.startsWith('/')) return; @@ -392,6 +400,24 @@ void Backend::describe(const QVariantMap &item, bool inTrash) { m_details.insert("sentence", sentence); emit detailsChanged(); }); + } else if (kind == "video" || kind == "audio") { + // A video's or a song's length and codecs, once ffprobe is done. + auto json = std::make_shared(); + // "file:" keeps a name with a colon from being taken for a protocol. + m_helper.run("ffprobe", {"-v", "error", "-print_format", "json", "-show_format", "-show_streams", + "file:" + QFileInfo(path).absoluteFilePath()}, + probeOutputLimit, [json](const QByteArray &output) { *json += output; }, + [this, details, json](int code, const QByteArray &) { + QVariantMap facts; + if (code == 0) + facts = mediaFacts(*json); + else if (code == Helper::missing) + facts.insert("sentence", tr("Lengths and codecs need ffprobe, from the ffmpeg package.")); + // Shown even when there's nothing to add: a file written over that ffprobe can't read loses the old one's length. + m_details = details; + m_details.insert(facts); + emit detailsChanged(); + }); } }; if (kind != "image" && kind != "text" && !inTrash) { @@ -481,6 +507,56 @@ QString Backend::typeName(const QString &mime, const QString &path) { return QMimeDatabase().mimeTypeForName(FontSample::typeOf(mime, path)).comment(); } +// ffprobe's names for common codecs as people write them; any other is upper-cased. +static QString codecName(const QString &name) { + static const QHash names{ + {"h264", "H.264"}, {"hevc", "H.265"}, {"vp8", "VP8"}, {"vp9", "VP9"}, {"av1", "AV1"}, + {"mpeg4", "MPEG-4"}, {"mpeg2video", "MPEG-2"}, {"mpeg1video", "MPEG-1"}, {"aac", "AAC"}, + {"mp3", "MP3"}, {"opus", "Opus"}, {"vorbis", "Vorbis"}, {"flac", "FLAC"}, {"ac3", "AC-3"}, + {"eac3", "E-AC-3"}, {"dts", "DTS"}, {"alac", "ALAC"}}; + if (name.startsWith("pcm_")) + return "PCM"; + return names.value(name, name.toUpper()); +} + +// Sample: {"streams": [{"codec_name": "h264", "codec_type": "video", "width": 1920, "height": 1080, "side_data_list": [{"rotation": -90}]}, {"codec_name": "aac", "codec_type": "audio"}], "format": {"duration": "125.400000"}} +QVariantMap Backend::mediaFacts(const QByteArray &json) { + const QJsonObject root = QJsonDocument::fromJson(json).object(); + QVariantMap facts; + for (const QJsonValue &value : root.value("streams").toArray()) { + const QJsonObject stream = value.toObject(); + const QString type = stream.value("codec_type").toString(); + const QString codec = codecName(stream.value("codec_name").toString()); + // A song's cover art is a video stream to ffprobe, not to a person. + const bool cover = stream.value("disposition").toObject().value("attached_pic").toInt() != 0; + if (type == "video" && !cover && !facts.contains("video")) { + facts.insert("video", codec); + int width = stream.value("width").toInt(), height = stream.value("height").toInt(); + // A phone's sideways recording is turned when it plays, so its size is given as it shows. + for (const QJsonValue &side : stream.value("side_data_list").toArray()) + if (qAbs(qRound(side.toObject().value("rotation").toDouble())) % 180 == 90) + std::swap(width, height); + // Numbers, as a picture's are: the pane writes them in the language's digits. + if (width > 0 && height > 0) { + facts.insert("width", width); + facts.insert("height", height); + } + } else if (type == "audio" && !facts.contains("audio")) { + facts.insert("audio", codec); + } + } + // A stream ffprobe couldn't name gives no codec row. + for (const QString &key : {QStringLiteral("video"), QStringLiteral("audio")}) + if (facts.value(key).toString().isEmpty()) + facts.remove(key); + // A live recording's dump has no duration at all: no length, rather than 0:00. + bool ok = false; + const double seconds = root.value("format").toObject().value("duration").toString().toDouble(&ok); + if (ok && std::isfinite(seconds) && seconds > 0) + facts.insert("seconds", seconds); + return facts; +} + Backend::~Backend() { if (s_working == this) s_working = nullptr; diff --git a/src/backend.h b/src/backend.h index aa5196b0..a1e38927 100644 --- a/src/backend.h +++ b/src/backend.h @@ -31,9 +31,9 @@ class Backend : public QObject { // What the details pane shows beyond the listing, for the item last // given to describe(), once they've been read in the background: a // picture's dimensions, a text's opening lines, an archive's names (or - // a sentence in their place), or a trashed item's origin and deletion - // time; and under `plugins`, plugins' rows about a file or folder, - // [[label, value]...]. + // a sentence in their place), a video's or a song's length and codecs, + // or a trashed item's origin and deletion time; and under `plugins`, + // plugins' rows about a file or folder, [[label, value]...]. Q_PROPERTY(QVariantMap details READ details NOTIFY detailsChanged) public: @@ -115,6 +115,8 @@ class Backend : public QObject { // A server address (sftp://, smb://, ftp://, dav://, davs://) as GIO // mounts it, or empty when the text isn't one. static QString serverAddress(const QString &text); + // ffprobe's JSON as details: seconds, width and height, video and audio codecs, only those it gave. + static QVariantMap mediaFacts(const QByteArray &json); signals: void pathChanged(); diff --git a/src/i18n/en.json b/src/i18n/en.json index b1531f26..8563813d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -62,6 +62,7 @@ "one": "At least %Ln more", "other": "At least %Ln more" }, + "Audio": "Audio", "Back / ahead 5 seconds": "Back / ahead 5 seconds", "Back / forward (or the mouse's thumb buttons)": "Back / forward (or the mouse's thumb buttons)", "Bigger, smaller or normal tiles (or Ctrl+scroll)": "Bigger, smaller or normal tiles (or Ctrl+scroll)", @@ -234,6 +235,8 @@ "Keep Both": "Keep Both", "Keyboard shortcuts": "Keyboard shortcuts", "Kind": "Kind", + "Length": "Length", + "Lengths and codecs need ffprobe, from the ffmpeg package.": "Lengths and codecs need ffprobe, from the ffmpeg package.", "Listing archives needs bsdtar, from the libarchive package.": "Listing archives needs bsdtar, from the libarchive package.", "Listing this archive took too long.": "Listing this archive took too long.", "Making a preview…": "Making a preview…", @@ -462,6 +465,7 @@ "Untitled Folder": "Untitled Folder", "Untitled Folder %1": "Untitled Folder %1", "User": "User", + "Video": "Video", "Views": "Views", "Works almost anywhere, old cameras and car stereos too, but no file can be over 4 GB.": "Works almost anywhere, old cameras and car stereos too, but no file can be over 4 GB.", "Works with Mac, Windows and Linux, and holds big files.": "Works with Mac, Windows and Linux, and holds big files.", diff --git a/tests/folio_tests.cpp b/tests/folio_tests.cpp index 3b3ef9f4..fe992d37 100644 --- a/tests/folio_tests.cpp +++ b/tests/folio_tests.cpp @@ -3361,6 +3361,98 @@ private slots: QCOMPARE(backend.title(), QString("Trash")); } + void backendReadsMediaFacts() + { + // A phone's clip: turned a quarter, with sound, past the hour. + const QVariantMap clip = Backend::mediaFacts(R"({"streams": [ + {"codec_name": "hevc", "codec_type": "video", "width": 1920, "height": 1080, + "side_data_list": [{"side_data_type": "Display Matrix", "rotation": -90}]}, + {"codec_name": "aac", "codec_type": "audio"}], + "format": {"duration": "3725.900000"}})"); + QCOMPARE(clip, (QVariantMap{{"seconds", 3725.9}, {"width", 1080}, {"height", 1920}, {"video", "H.265"}, {"audio", "AAC"}})); + + // A song with its cover art: the cover gives no picture size or video codec. + const QVariantMap song = Backend::mediaFacts(R"({"streams": [ + {"codec_name": "pcm_s16le", "codec_type": "audio"}, + {"codec_name": "mjpeg", "codec_type": "video", "width": 600, "height": 600, + "disposition": {"attached_pic": 1}}], + "format": {"duration": "59.990000"}})"); + QCOMPARE(song, (QVariantMap{{"seconds", 59.99}, {"audio", "PCM"}})); + + // A dump with no duration has no length; a codec without a name of + // its own keeps ffprobe's, and one without any gives no row. + const QVariantMap dump = Backend::mediaFacts(R"({"streams": [ + {"codec_name": "prores", "codec_type": "video", "width": 3840, "height": 2160}, + {"codec_type": "audio"}], "format": {}})"); + QCOMPARE(dump, (QVariantMap{{"width", 3840}, {"height", 2160}, {"video", "PRORES"}})); + QVERIFY(Backend::mediaFacts("not json").isEmpty()); + } + + void backendAsksFfprobeAboutMedia() + { + // A stand-in ffprobe that answers for clip.mp4 unless a file named broken is beside it, fails on anything else, and notes what it was asked. + QTemporaryDir dir, empty; + const QString asked = dir.filePath("asked"); + write(dir.filePath("answer.json"), R"({"streams": [{"codec_name": "h264", "codec_type": "video", + "width": 1280, "height": 720}, {"codec_name": "opus", "codec_type": "audio"}], "format": {"duration": "65.4"}})"); + write(dir.filePath("ffprobe"), "#!/bin/sh\nPATH=/usr/bin:/bin\nfor a in \"$@\"; do echo \"$a\"; done > '" + asked.toUtf8() + + "'\ncase \"$*\" in *'/-a: clip.mp4') [ -e '" + dir.filePath("broken").toUtf8() + "' ] && exit 1; cat '" + + dir.filePath("answer.json").toUtf8() + + "' ;; *) exit 1 ;; esac\n"); + QVERIFY(QFile::setPermissions(dir.filePath("ffprobe"), QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)); + const QString clip = dir.filePath("-a: clip.mp4"); + write(clip, "x"); + + Backend backend(dir.path()); + { + const auto restore = pathAt(dir.path()); + backend.describe({{"path", clip}, {"kind", "video"}, {"mime", "video/mp4"}}); + QVERIFY(!backend.details().contains("seconds")); + QTRY_COMPARE(backend.details().value("seconds").toDouble(), 65.4); + QCOMPARE(backend.details().value("width").toInt(), 1280); + QCOMPARE(backend.details().value("height").toInt(), 720); + QCOMPARE(backend.details().value("video").toString(), QString("H.264")); + QCOMPARE(backend.details().value("audio").toString(), QString("Opus")); + QFile file(asked); + QVERIFY(file.open(QIODevice::ReadOnly)); + QCOMPARE(QString::fromUtf8(file.readAll()).split('\n').value(6), "file:" + clip); + + // The clip written over with something ffprobe can't read loses the length and codecs it showed. + write(dir.filePath("broken"), "x"); + QVERIFY(QFile::remove(asked)); + backend.describe({{"path", clip}, {"kind", "video"}, {"mime", "video/mp4"}}); + QTRY_VERIFY(QFile::exists(asked)); + QTRY_VERIFY(!backend.details().contains("seconds")); + QVERIFY(!backend.details().contains("video")); + QVERIFY(QFile::remove(dir.filePath("broken"))); + + // ffprobe failing says nothing. + QVERIFY(QFile::remove(asked)); + backend.describe({{"path", dir.filePath("song.mp3")}, {"kind", "audio"}, {"mime", "audio/mpeg"}}); + QTRY_VERIFY(QFile::exists(asked)); + QTest::qWait(200); + QVERIFY(backend.details().isEmpty()); + } + // Without ffprobe, the package that has it is named. + const auto restore = pathAt(empty.path()); + backend.describe({{"path", clip}, {"kind", "video"}, {"mime", "video/mp4"}}); + QTRY_COMPARE(backend.details().value("sentence").toString(), QString("Lengths and codecs need ffprobe, from the ffmpeg package.")); + QVERIFY(!backend.details().contains("seconds")); + } + + void formatShowsLengthsAsAClock() + { + QQmlEngine engine; + QQmlComponent lengths(&engine); + lengths.setData("import QtQml\nimport \"Format.js\" as Format\n" + "QtObject { property var shown: [0, 0.4, 59.99, 65, 3599, 3725.9, 36000].map(Format.length) }", + QUrl::fromLocalFile(QFINDTESTDATA("../src/Format.js"))); + std::unique_ptr object(lengths.create()); + QVERIFY2(object, qPrintable(lengths.errorString())); + QCOMPARE(object->property("shown").toStringList(), + QStringList({"", "0:00", "0:59", "1:05", "59:59", "1:02:05", "10:00:00"})); + } + void placesListEveryFolderInHome() { QTemporaryDir home;