diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4c0296d6..9e397a6b 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Install Apt Dependencies
- run: sudo apt update && sudo apt install ninja-build qtbase5-dev qttools5-dev libboost-dev libboost-date-time-dev libboost-iostreams-dev nlohmann-json3-dev libasound2-dev librtmidi-dev libminizip-dev doctest-dev
+ run: sudo apt update && sudo apt install ninja-build qtbase5-dev qttools5-dev libboost-dev libboost-date-time-dev libboost-iostreams-dev nlohmann-json3-dev libasound2-dev librtmidi-dev libminizip-dev doctest-dev libfmt-dev
- name: Install Other Dependencies
run: vcpkg install pugixml
- name: Create Build Directory
@@ -44,7 +44,7 @@ jobs:
- name: Install Dependencies
# CMake 3.17 is already installed
- run: brew install boost doctest minizip ninja nlohmann-json pugixml qt5 pugixml rtmidi
+ run: brew install boost doctest minizip ninja nlohmann-json pugixml qt5 pugixml rtmidi fmt
- name: Generate Project
run: cmake -S ${GITHUB_WORKSPACE} -B ${{runner.workspace}}/build -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_PREFIX_PATH=/usr/local/opt/qt5/lib/cmake
- name: Build
@@ -98,7 +98,7 @@ jobs:
run: echo "::set-output name=VERSION_ID::$(git describe --tags --long --always)"
- name: Install Dependencies
- run: vcpkg install --triplet ${{ matrix.arch }}-windows boost-algorithm boost-date-time boost-endian boost-functional boost-iostreams boost-range boost-rational boost-signals2 boost-stacktrace doctest minizip nlohmann-json pugixml
+ run: vcpkg install --triplet ${{ matrix.arch }}-windows boost-algorithm boost-date-time boost-endian boost-functional boost-iostreams boost-range boost-rational boost-signals2 boost-stacktrace doctest minizip nlohmann-json pugixml fmt
# Building Qt via vcpkg would take a while ...
- name: Install Qt
diff --git a/README.md b/README.md
index c4991e74..49bf94be 100644
--- a/README.md
+++ b/README.md
@@ -82,9 +82,10 @@ Power Tab Editor 2.0 - A powerful cross platform guitar tablature viewer and edi
* [pugixml](https://pugixml.org/)
* [minizip](https://github.com/madler/zlib)
* [doctest](https://github.com/onqtam/doctest)
+* [fmtlib](https://github.com/fmtlib/fmt)
* (Linux only) - ALSA library (e.g. `libasound2-dev`)
* (Linux only) - MIDI sequencer (e.g. `timidity-daemon`)
-* A compiler with C++17 support.
+* A compiler with C++20 support.
### Building:
#### Windows:
diff --git a/cmake/PTE_ThirdParty.cmake b/cmake/PTE_ThirdParty.cmake
index ad5da565..c02c0bb0 100644
--- a/cmake/PTE_ThirdParty.cmake
+++ b/cmake/PTE_ThirdParty.cmake
@@ -11,3 +11,4 @@ include ( third_party/nlohmann_json )
include ( third_party/pugixml )
include ( third_party/Qt )
include ( third_party/rtmidi )
+include ( third_party/fmt )
diff --git a/cmake/third_party/fmt.cmake b/cmake/third_party/fmt.cmake
new file mode 100644
index 00000000..31a2e99a
--- /dev/null
+++ b/cmake/third_party/fmt.cmake
@@ -0,0 +1 @@
+find_package ( fmt 8.0.0 REQUIRED )
diff --git a/source/app/CMakeLists.txt b/source/app/CMakeLists.txt
index 68de5d64..6a5cc02f 100644
--- a/source/app/CMakeLists.txt
+++ b/source/app/CMakeLists.txt
@@ -23,6 +23,7 @@ set( headers
command.h
documentmanager.h
paths.h
+ log.h
powertabeditor.h
recentfiles.h
scorearea.h
diff --git a/source/app/log.h b/source/app/log.h
new file mode 100644
index 00000000..50c39fad
--- /dev/null
+++ b/source/app/log.h
@@ -0,0 +1,37 @@
+/*
+ * Copyright (C) 2024 Simon Symeonidis
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef APP_LOG_H
+#define APP_LOG_H
+
+#include
+
+#include
+#include
+#include
+#include
+
+/* Adapted from: https://fmt.dev/latest/api.html#udt */
+template <>
+struct fmt::formatter : fmt::formatter
+{
+ auto format(const QString& qstr, fmt::format_context& ctx) const {
+ return fmt::formatter::format(qstr.toStdString(), ctx);
+ }
+};
+
+#endif
diff --git a/source/app/paths.cpp b/source/app/paths.cpp
index 4d5bce2c..bbb9e0c8 100644
--- a/source/app/paths.cpp
+++ b/source/app/paths.cpp
@@ -39,6 +39,11 @@ path getConfigDir()
#endif
}
+path getLogPath()
+{
+ return getConfigDir() / "log.txt";
+}
+
path getUserDataDir()
{
return fromQString(
diff --git a/source/app/paths.h b/source/app/paths.h
index 898a3ab4..e81bb6cf 100644
--- a/source/app/paths.h
+++ b/source/app/paths.h
@@ -26,6 +26,9 @@ namespace Paths {
/// Return a path to a directory where config files should be written to.
path getConfigDir();
+ /// Return a path to the log file
+ path getLogPath();
+
/// Return a path to a directory where persistent application data should
/// be written to.
path getUserDataDir();
diff --git a/source/app/powertabeditor.cpp b/source/app/powertabeditor.cpp
index f6c952d2..b596bfb7 100644
--- a/source/app/powertabeditor.cpp
+++ b/source/app/powertabeditor.cpp
@@ -79,6 +79,7 @@
#include
#include
#include
+#include
#include
#include
#include
@@ -260,14 +261,14 @@ void PowerTabEditor::openFile(QString filename)
int validationResult = myDocumentManager->findDocument(path);
if (validationResult > -1)
{
- qDebug() << "File: " << filename << " is already open";
+ Log::d("file: {} is already open", filename);
myTabWidget->setCurrentIndex(validationResult);
return;
}
auto start = std::chrono::high_resolution_clock::now();
- qDebug() << "Opening file: " << filename;
+ Log::d("opening file: {}", filename);
QFileInfo fileInfo(filename);
std::optional format = myFileFormatManager->findFormat(
@@ -285,9 +286,8 @@ void PowerTabEditor::openFile(QString filename)
Document &doc = myDocumentManager->addDocument(*mySettingsManager);
myFileFormatManager->importFile(doc.getScore(), path, *format);
auto end = std::chrono::high_resolution_clock::now();
- qDebug() << "File loaded in"
- << std::chrono::duration_cast(end - start) .count()
- << "ms";
+
+ Log::d("file loaded in: {}ms", std::chrono::duration_cast(end - start).count());
doc.setFilename(path);
setPreviousDirectory(filename);
@@ -3616,7 +3616,7 @@ void PowerTabEditor::setPreviousDirectory(const QString &fileName)
void PowerTabEditor::setupNewTab()
{
auto start = std::chrono::high_resolution_clock::now();
- qDebug() << "Tab creation started ...";
+ Log::d("tab creation started...");
Q_ASSERT(myDocumentManager->hasOpenDocuments());
Document &doc = myDocumentManager->getCurrentDocument();
@@ -3788,9 +3788,7 @@ void PowerTabEditor::setupNewTab()
scorearea->setFocus();
auto end = std::chrono::high_resolution_clock::now();
- qDebug() << "Tab opened in"
- << std::chrono::duration_cast(
- end - start).count() << "ms";
+ Log::d("tab opened in: {}ms", std::chrono::duration_cast(end - start).count());
}
namespace
diff --git a/source/app/scorearea.cpp b/source/app/scorearea.cpp
index 4ae21258..3a9c6949 100644
--- a/source/app/scorearea.cpp
+++ b/source/app/scorearea.cpp
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
+
#include "scorearea.h"
#include
@@ -32,6 +32,7 @@
#include
#include
#include
+#include
void ScoreArea::Scene::dragEnterEvent(QGraphicsSceneDragDropEvent *event)
{
@@ -114,7 +115,7 @@ void ScoreArea::renderDocument(const Document &document)
#endif
std::vector> tasks;
const int work_size = myRenderedSystems.size() / num_threads;
- qDebug() << "Using" << num_threads << "worker thread(s)";
+ Log::d("using {} worker thread(s)", num_threads);
for (int i = 0; i < num_threads; ++i)
{
@@ -162,10 +163,13 @@ void ScoreArea::renderDocument(const Document &document)
myScene.setSceneRect(myScene.itemsBoundingRect());
auto end = std::chrono::high_resolution_clock::now();
- qDebug() << "Score rendered in"
- << std::chrono::duration_cast(
- end - start).count() << "ms";
- qDebug() << "Rendered " << myScene.items().size() << "items";
+
+ namespace sc = std::chrono;
+ const auto time_elapsed = static_cast
+ (sc::duration_cast(end - start).count());
+
+ Log::d("score rendered in {} ms", time_elapsed);
+ Log::d("rendered {} items", myScene.items().size());
}
void ScoreArea::redrawSystem(int index)
diff --git a/source/app/settingsmanager.cpp b/source/app/settingsmanager.cpp
index b1083223..be0f5bcc 100644
--- a/source/app/settingsmanager.cpp
+++ b/source/app/settingsmanager.cpp
@@ -17,6 +17,8 @@
#include "settingsmanager.h"
+#include
+
#include
#include
@@ -47,7 +49,7 @@ void SettingsManager::load(const std::filesystem::path &dir)
}
catch (const std::exception &e)
{
- std::cerr << "Error loading " << path << ": " << e.what() << std::endl;
+ Log::e("error loading {}: {}", path.generic_string(), e.what());
}
#endif
}
diff --git a/source/app/tuningdictionary.cpp b/source/app/tuningdictionary.cpp
index 35b8be2e..284e876b 100644
--- a/source/app/tuningdictionary.cpp
+++ b/source/app/tuningdictionary.cpp
@@ -26,6 +26,7 @@
#include
#include
#include
+#include
static const char *theTuningDictFilename = "tunings.json";
@@ -70,11 +71,11 @@ TuningDictionary::load()
if (entries.empty())
{
- std::cerr << "Could not locate tuning dictionary." << std::endl;
- std::cerr << "Candidate paths:" << std::endl;
+ Log::e("could not locate tuning dictionary");
+ Log::e("candidate paths: ");
for (std::filesystem::path dir : Paths::getDataDirs())
- std::cerr << (dir / theTuningDictFilename) << std::endl;
+ Log::e(" - {}", (dir / theTuningDictFilename).generic_string());
}
return entries;
diff --git a/source/audio/midioutputdevice.cpp b/source/audio/midioutputdevice.cpp
index b4aed5a4..1c8d4273 100644
--- a/source/audio/midioutputdevice.cpp
+++ b/source/audio/midioutputdevice.cpp
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
+
#include "midioutputdevice.h"
#include
@@ -22,6 +22,7 @@
#include
#include
#include
+#include
#ifdef __APPLE__
#include "midisoftwaresynth.h"
@@ -38,7 +39,7 @@ MidiOutputDevice::MidiOutputDevice() : myMidiOut(nullptr)
}
catch (std::exception &e)
{
- std::cerr << e.what() << std::endl;
+ Log::e("could not start midisoftwaresynth: {}", e.what());
};
#endif
diff --git a/source/build/main.cpp b/source/build/main.cpp
index 882dc12f..a741a9e5 100644
--- a/source/build/main.cpp
+++ b/source/build/main.cpp
@@ -14,8 +14,9 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
+
#include
+#include
#include
#include
#include
@@ -60,7 +61,7 @@ static void displayError(const std::string &reason)
// If there is no QApplication instance, something went seriously wrong
// during startup - just dump the error to the console.
if (!QApplication::instance())
- std::cerr << message << std::endl;
+ Log::e("{}", message);
else
{
CrashDialog dialog(QString::fromStdString(message),
@@ -135,23 +136,25 @@ loadTranslations(QApplication &app, QTranslator &qt_translator,
QTranslator &ptb_translator)
{
QLocale locale;
- qDebug() << "Finding translations for locale" << locale
- << "with UI languages" << locale.uiLanguages();
+
+ Log::d("finding translations for locale:");
+ for (const auto& loc : locale.uiLanguages())
+ Log::d(" locale: {}", loc);
for (auto &&path : Paths::getTranslationDirs())
{
QString dir = Paths::toQString(path);
- qDebug() << " - Checking" << dir;
+ Log::d(" - checking: {}", path.generic_string());
if (ptb_translator.isEmpty() &&
ptb_translator.load(locale, QStringLiteral("powertabeditor"),
QStringLiteral("_"), dir))
{
#if (QT_VERSION >= QT_VERSION_CHECK(5,15,0))
- qDebug() << "Loaded application translations from"
- << ptb_translator.filePath();
+ Log::d("loaded application translations from: {}",
+ ptb_translator.filePath());
#else
- qDebug() << "Loaded application translations";
+ Log::d("loaded application translations");
#endif
app.installTranslator(&ptb_translator);
}
@@ -161,10 +164,10 @@ loadTranslations(QApplication &app, QTranslator &qt_translator,
QStringLiteral("_"), dir))
{
#if (QT_VERSION >= QT_VERSION_CHECK(5,15,0))
- qDebug() << "Loaded Qt base translations from"
- << qt_translator.filePath();
+ Log::d("loaded qt base translations from {}",
+ qt_translator.filePath());
#else
- qDebug() << "Loaded Qt base translations";
+ Log::d("loaded Qt base translations");
#endif
app.installTranslator(&qt_translator);
}
@@ -173,6 +176,10 @@ loadTranslations(QApplication &app, QTranslator &qt_translator,
int main(int argc, char *argv[])
{
+ Log::init(Log::Level::Debug, Paths::getLogPath());
+
+ Log::d("started powertab editor ({})", AppInfo::APPLICATION_VERSION);
+
// Register handlers for unhandled exceptions and segmentation faults.
std::set_terminate(terminateHandler);
std::signal(SIGSEGV, signalHandler);
diff --git a/source/dialogs/infodialog.cpp b/source/dialogs/infodialog.cpp
index c3aec5fa..827f38a0 100644
--- a/source/dialogs/infodialog.cpp
+++ b/source/dialogs/infodialog.cpp
@@ -19,6 +19,9 @@
#include "ui_infodialog.h"
#include
+#include
+
+#include
#include
@@ -47,9 +50,15 @@ void InfoDialog::setInfo()
tr("You can grab development binaries here:\n"
" https://github.com/powertab/powertabeditor/actions");
- const auto message = QString("%1\n\n%2").arg(
+ const auto message = QString(
+ "%1\n\n"
+ "%2\n\n"
+ "===============================\n"
+ "Logs:\n%3"
+ ).arg(
qname,
- developmentBinaryLocation
+ developmentBinaryLocation,
+ QString::fromStdString(Log::all())
);
ui->appInfo->setText(message);
diff --git a/source/formats/gp7/from_xml.cpp b/source/formats/gp7/from_xml.cpp
index 74800336..1de16272 100644
--- a/source/formats/gp7/from_xml.cpp
+++ b/source/formats/gp7/from_xml.cpp
@@ -19,6 +19,7 @@
#include
#include
+#include
#include
#include
@@ -167,8 +168,7 @@ parseChordNote(const pugi::xml_node &node)
note.myAccidental = *accidental;
else
{
- std::cerr << "Unknown accidental type: " << accidental_text
- << std::endl;
+ Log::e("unknown accidental type: {}", accidental_text);
}
return note;
@@ -189,7 +189,7 @@ parseChordDegree(const pugi::xml_node &chord_node, const char *name)
if (auto alteration = Util::toEnum(text))
degree.myAlteration = *alteration;
else
- std::cerr << "Unknown alteration type: " << text << std::endl;
+ Log::e("unknown alteration type: {}", text);
return degree;
}
@@ -425,8 +425,7 @@ parseMasterBars(const pugi::xml_node &master_bars_node)
master_bar.myDirectionTargets.push_back(*target);
else
{
- std::cerr << "Invalid direction target type: " << target_str
- << std::endl;
+ Log::e("invalid direction target type: {}", target_str);
}
}
@@ -438,8 +437,7 @@ parseMasterBars(const pugi::xml_node &master_bars_node)
master_bar.myDirectionJumps.push_back(*jump);
else
{
- std::cerr << "Invalid direction jump type: " << jump_str
- << std::endl;
+ Log::e("invalid direction jump type: {}", jump_str);
}
}
}
@@ -463,7 +461,7 @@ parseBars(const pugi::xml_node &bars_node)
if (auto clef_type = Util::toEnum(clef_name))
bar.myClefType = *clef_type;
else
- std::cerr << "Invalid clef type: " << clef_name << std::endl;
+ Log::e("invalid clef type: {}", clef_name);
// TODO - import the 'Ottavia' key if the clef has 8va, etc
@@ -507,7 +505,7 @@ parseBeats(const pugi::xml_node &beats_node, Gp7::Version version)
{
beat.myOttavia = Util::toEnum(ottavia);
if (!beat.myOttavia)
- std::cerr << "Invalid ottavia value: " << ottavia << std::endl;
+ Log::e("invalid ottavia value: {}", ottavia);
}
beat.myFreeText = node.child_value("FreeText");
@@ -654,8 +652,7 @@ parseNotes(const pugi::xml_node ¬es_node)
note.myHarmonic = Util::toEnum(harmonic_type);
if (!note.myHarmonic)
{
- std::cerr << "Unknown harmonic type: " << harmonic_type
- << std::endl;
+ Log::e("unknown harmonic type: {}", harmonic_type);
}
}
else if (name == "Bended")
@@ -718,8 +715,7 @@ parseNotes(const pugi::xml_node ¬es_node)
note.myLeftFinger = Util::toEnum(finger_type);
if (!note.myLeftFinger)
{
- std::cerr << "Unknown finger type: " << finger_type
- << std::endl;
+ Log::e("unknown finger type: {}", finger_type);
}
}
diff --git a/source/formats/gp7/to_pt2.cpp b/source/formats/gp7/to_pt2.cpp
index 6f8635e0..3bc38941 100644
--- a/source/formats/gp7/to_pt2.cpp
+++ b/source/formats/gp7/to_pt2.cpp
@@ -38,6 +38,7 @@
#include
#include
#include
+#include
#include
#include
@@ -182,7 +183,7 @@ getHarmonicPitchOffset(double harmonic_fret)
}
else
{
- std::cerr << "Unexpected harmonic type" << std::endl;
+ Log::e("unexpected harmonic type");
return 12;
}
}
diff --git a/source/formats/powertab_old/powertabdocument/macros.cpp b/source/formats/powertab_old/powertabdocument/macros.cpp
index a3ba272b..a3178c51 100644
--- a/source/formats/powertab_old/powertabdocument/macros.cpp
+++ b/source/formats/powertab_old/powertabdocument/macros.cpp
@@ -14,14 +14,19 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
-
+
#include "macros.h"
+
+#include
+
#include // std::transform
#include
void logToDebug(const std::string& msg, const std::string& file, int line)
{
- std::cerr << msg << std::endl << file << std::endl << "Line: " << line << std::endl;
+ Log::e("{}", msg);
+ Log::e("{}", file);
+ Log::e("line: {}", line);
}
std::string ArabicToRoman(uint32_t number, bool upperCase)
diff --git a/source/score/serialization.cpp b/source/score/serialization.cpp
index ff426ad1..c1980503 100644
--- a/source/score/serialization.cpp
+++ b/source/score/serialization.cpp
@@ -42,8 +42,7 @@ InputArchive::InputArchive(std::istream &is)
}
else
{
- std::cerr << "Warning: Reading an unknown file version - " << version
- << std::endl;
+ Log::e("warning: reading an unknown file version - {}", version);
// Reading in a newer version. Just do the best we can with the latest
// file version we're aware of.
diff --git a/source/score/serialization.h b/source/score/serialization.h
index d36051b1..e060ca47 100644
--- a/source/score/serialization.h
+++ b/source/score/serialization.h
@@ -31,6 +31,7 @@
#include
#include
#include
+#include
#include
namespace ScoreUtils
@@ -113,8 +114,7 @@ namespace detail
val = *result;
else
{
- std::cerr << "Unknown enum value: " << text
- << std::endl;
+ Log::e("unknown enum value: {}", text);
}
}
}
diff --git a/source/score/utils/directionindex.cpp b/source/score/utils/directionindex.cpp
index 3d663221..51445669 100644
--- a/source/score/utils/directionindex.cpp
+++ b/source/score/utils/directionindex.cpp
@@ -19,6 +19,7 @@
#include
#include
+#include
DirectionIndex::DirectionIndex(const Score &score)
: myScore(score), myActiveSymbol(DirectionSymbol::ActiveNone)
@@ -147,8 +148,7 @@ SystemLocation DirectionIndex::followDirection(DirectionSymbol::SymbolType type)
else
{
// This should not happen if the score is properly written.
- std::cerr << "Could not find the symbol "
- << static_cast(nextSymbol) << std::endl;
+ Log::e("Could not find the symbol {}", static_cast(nextSymbol));
return SystemLocation(0, 0);
}
}
diff --git a/source/util/CMakeLists.txt b/source/util/CMakeLists.txt
index 436aa137..b2037adf 100644
--- a/source/util/CMakeLists.txt
+++ b/source/util/CMakeLists.txt
@@ -32,6 +32,7 @@ if ( PLATFORM_OSX )
endif ()
set( srcs
+ log.cpp
settingstree.cpp
version.cpp
@@ -43,6 +44,7 @@ set( headers
enumflags.h
enumtostring.h
enumtostring_fwd.h
+ log.h
settingstree.h
tostring.h
toutf8.h
@@ -62,5 +64,5 @@ pte_library(
HEADERS ${headers}
DEPENDS
PUBLIC Boost::headers
- PRIVATE nlohmann_json::nlohmann_json ${platform_depends}
+ PRIVATE fmt::fmt nlohmann_json::nlohmann_json ${platform_depends}
)
diff --git a/source/util/log.cpp b/source/util/log.cpp
new file mode 100644
index 00000000..51147b1b
--- /dev/null
+++ b/source/util/log.cpp
@@ -0,0 +1,138 @@
+/* Copyright (C) 2021-2024 Simon Symeonidis
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+#include "log.h"
+
+#include
+#include
+#include /* put_time */
+#include
+#include
+#include
+#include
+#include
+
+namespace Log {
+
+enum Level FilterLevel = Level::Debug;
+
+std::optional logFile;
+
+std::filesystem::path logPath;
+
+std::mutex lock;
+
+void trim(const unsigned int count)
+{
+ /* a poorman's circular buffer that keeps the last 'count' log lines. */
+ std::vector v(count);
+
+ std::ifstream input(logPath);
+ std::size_t index = 0;
+ std::string s;
+ while (std::getline(input, s)) {
+ v[index % count] = s;
+ index++;
+ }
+
+ input.close();
+
+ // required because of len/cap discrepancy in vector used as a circular
+ // buffer.
+ const auto upto = (count == index ? index : count);
+
+ std::ofstream output(logPath, std::ios::trunc);
+ if (output.good()) {
+ for (size_t i = 0; i < upto; ++i)
+ output << v[(index + i) % count] << std::endl;
+ } else {
+ std::cerr << "could not open log file for trimming" << std::endl;
+ }
+ output.close();
+}
+
+void init(enum Level lvl, std::filesystem::path lp)
+{
+ FilterLevel = lvl;
+ logPath = lp;
+
+ // keep only the last X lines
+ trim(1000);
+
+ if (!logFile) {
+ const auto mode = std::ios_base::out | std::ios_base::app;
+ logFile = std::ofstream(logPath, mode);
+ }
+}
+
+void emitLog(enum Level level, const std::string& str)
+{
+ std::lock_guard _guard(Log::lock);
+
+ /* formats a timestamp in a rfc3339 fashion; I believe the system clock
+ * defaults to epoch, which should be UTC, but I might be wrong. */
+ const auto timestamp_now_str_fn = []() -> std::string {
+ auto now = std::chrono::system_clock::now();
+ auto now_t = std::chrono::system_clock::to_time_t(now);
+
+ std::stringstream ss;
+ ss << std::put_time(std::localtime(&now_t), "%Y-%m-%dT%H:%M:%SZ");
+ return ss.str();
+ };
+
+ const auto level_str_fn = [](enum Level l) noexcept {
+ switch (l) {
+ case Level::Debug: return "[debug]";
+ case Level::Info: return "[info]";
+ case Level::Notify: return "[notify]";
+ case Level::Warning: return "[warn]";
+ case Level::Error: return "[error]";
+ /* unreachable */
+ default:
+ return "unknown";
+ }
+ };
+
+ std::cout
+ << timestamp_now_str_fn() << ": "
+ << level_str_fn(level) << ": "
+ << str
+ << std::endl;
+
+ if (logFile.value().good()) {
+ logFile.value()
+ << timestamp_now_str_fn() << ": "
+ << level_str_fn(level) << ": "
+ << str
+ << std::endl;
+ } else {
+ std::cout << fmt::format(
+ "{} {} could not open file at ({})",
+ timestamp_now_str_fn(),
+ level_str_fn(Level::Warning),
+ logPath.generic_string()
+ );
+ }
+}
+
+std::string all()
+{
+ std::stringstream ss;
+ std::ifstream ifs(logPath);
+ ss << ifs.rdbuf();
+ return ss.str();
+}
+
+}
diff --git a/source/util/log.h b/source/util/log.h
new file mode 100644
index 00000000..78543410
--- /dev/null
+++ b/source/util/log.h
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2021-2024 Simon Symeonidis
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#ifndef UTIL_LOG_H
+#define UTIL_LOG_H
+
+#include
+#include
+
+/* TODO: fmtlib: replace with std, when all compilers support `format'. */
+#include
+
+namespace Log {
+
+enum class Level {
+ Debug = 0,
+ Info = 1,
+ Notify = 2,
+ Warning = 3,
+ Error = 4,
+};
+
+/**
+ * toggleable log level - set this to the minimum level to filter from a la
+ * rfc5424.
+ */
+extern enum Level CurrentLevel;
+
+void init(enum Level lvl, std::filesystem::path logPath);
+
+/**
+ * return the full contents of the log file
+ */
+std::string all();
+
+/**
+ * takes a string, and emits it. Our emitters consider only a log file on the
+ * platform, and print to console if there is a problem if the file can not be
+ * opened. The emitters will also add the current timestamp, and the log level
+ * of the message.
+ */
+void emitLog(enum Level level, const std::string& str);
+
+template
+void backend(const enum Level level, std::string_view fmt, Args&&... args)
+{
+ emitLog(level, fmt::vformat(fmt, fmt::make_format_args(args...)));
+}
+
+template
+void d(std::string_view fmt, Args&&... args) { backend(Level::Debug, fmt, std::forward(args)...); }
+
+template
+void i(std::string_view fmt, Args&&... args) { backend(Level::Info, fmt, std::forward(args)...); }
+
+template
+void n(std::string_view fmt, Args&&... args) { backend(Level::Notify, fmt, std::forward(args)...); }
+
+template
+void w(std::string_view fmt, Args&&... args) { backend(Level::Warning, fmt, std::forward(args)...); }
+
+template
+void e(std::string_view fmt, Args&&... args) { backend(Level::Error, fmt, std::forward(args)...); }
+
+}
+#endif
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 5ff47c82..3524e10c 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -188,6 +188,7 @@ pte_executable(
PCH_EXCLUDE test_main.cpp
DEPENDS
doctest::doctest
+ fmt::fmt
pteapp
rtmidi::rtmidi
)