tui support themes

This commit is contained in:
Boof2015
2026-08-15 18:45:32 -04:00
parent 700c8df4af
commit 1bb096c231
7 changed files with 1373 additions and 143 deletions
+4
View File
@@ -38,6 +38,7 @@ add_library(prism_tui_analysis STATIC
src/scrolling_history.cpp
src/scope_plot_model.cpp
src/spectrum_peak_model.cpp
src/tui_theme.cpp
src/tui_settings.cpp
${PRISM_NATIVE_DIR}/spectrum.cpp
${PRISM_NATIVE_DIR}/oscilloscope.cpp
@@ -52,6 +53,9 @@ target_include_directories(prism_tui_analysis PUBLIC
src
${PRISM_NATIVE_DIR})
target_link_libraries(prism_tui_analysis PUBLIC Threads::Threads)
if(WIN32)
target_link_libraries(prism_tui_analysis PUBLIC shell32 ole32)
endif()
add_library(prism_system_capture STATIC)
target_include_directories(prism_system_capture PUBLIC ${PRISM_NATIVE_DIR})
+257 -138
View File
@@ -8,6 +8,7 @@
#include "scope_plot_model.h"
#include "snapshot_store.h"
#include "tui_settings.h"
#include "tui_theme.h"
#include <ftxui/component/component.hpp>
#include <ftxui/component/event.hpp>
@@ -147,6 +148,7 @@ struct InterfaceState {
PanelId focusedPanel = PanelId::Spectrum;
std::optional<PanelId> expandedPanel;
TuiSettings settings;
TuiTheme theme = defaultTuiTheme();
bool layoutEditing = false;
LayoutOverlay layoutOverlay = LayoutOverlay::None;
size_t layoutAddSelection = 0;
@@ -154,7 +156,7 @@ struct InterfaceState {
bool settingsOpen = false;
SettingsPage settingsPage = SettingsPage::Home;
size_t settingsHomeSelection = 0;
std::array<size_t, 9> settingsSelections{};
std::array<size_t, 10> settingsSelections{};
std::string settingsStatus;
bool profilesOpen = false;
ProfileOverlayMode profileMode = ProfileOverlayMode::Browse;
@@ -168,6 +170,39 @@ struct InterfaceState {
std::string pendingProfileId;
};
const TuiTheme* renderTheme = nullptr;
const TuiTheme& palette() {
static const TuiTheme fallback = defaultTuiTheme();
return renderTheme == nullptr ? fallback : *renderTheme;
}
ftxui::Color terminalColor(const ThemeColor& color) {
return ftxui::Color::RGB(color.red, color.green, color.blue);
}
void fillCanvasBackground(ftxui::Canvas& surface,
const ftxui::Color& background) {
// Canvas nodes replace their parent cells after FTXUI's bgcolor decorator
// has run. Seed every terminal cell in the canvas so scope backgrounds are
// retained instead of exposing the terminal's own background.
for (int y = 0; y < surface.height(); y += 4) {
for (int x = 0; x < surface.width(); x += 2) {
surface.Style(x, y, [background](ftxui::Cell& cell) {
cell.background_color = background;
});
}
}
}
ThemeColor scaleColor(const ThemeColor& color, float brightness) {
const auto channel = [brightness](uint8_t value) {
return static_cast<uint8_t>(std::lround(std::clamp(
static_cast<float>(value) * brightness, 0.0f, 255.0f)));
};
return {channel(color.red), channel(color.green), channel(color.blue)};
}
const TuiProfile* activeProfile(const InterfaceState& state) {
const auto found = std::find_if(
state.profiles.begin(), state.profiles.end(), [&](const auto& profile) {
@@ -360,13 +395,31 @@ ftxui::Element panelTitle(PanelId panel,
label += " ";
auto title = text(label);
return focused
? title | color(Color::CyanLight) | bold
: title | color(Color::GrayDark);
? title | color(terminalColor(palette().accent)) | bold
: title | color(terminalColor(palette().muted));
}
ftxui::Element stylePanel(ftxui::Element content, bool focused) {
ThemeColor panelBackground(PanelId panel) {
const auto& theme = palette();
switch (panel) {
case PanelId::Spectrum: return theme.spectrumBackground;
case PanelId::Oscilloscope: return theme.oscilloscopeBackground;
case PanelId::Vectorscope: return theme.vectorscopeBackground;
case PanelId::VUMeter: return theme.vuBackground;
case PanelId::LUFSMeter: return theme.lufsBackground;
case PanelId::Spectrogram: return theme.spectrogramBackground;
case PanelId::Waveform: return theme.waveformBackground;
}
return theme.background;
}
ftxui::Element stylePanel(ftxui::Element content,
bool focused,
PanelId panel) {
using namespace ftxui;
return content | color(focused ? Color::GrayLight : Color::GrayDark);
return content |
color(terminalColor(focused ? palette().text : palette().muted)) |
bgcolor(terminalColor(panelBackground(panel)));
}
ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
@@ -393,12 +446,13 @@ ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
Elements spectrumElements;
spectrumElements.reserve(rows.size() + 1);
for (const auto& row : rows) {
spectrumElements.push_back(text(row) | color(Color::Cyan));
spectrumElements.push_back(
text(row) | color(terminalColor(palette().spectrumLine)));
}
if (contentHeight > 1) {
spectrumElements.push_back(
text(buildFrequencyAxis(contentWidth, projectionOptions.maxFrequency)) |
color(Color::GrayDark));
color(terminalColor(palette().spectrumLabels)));
}
const std::string detail = settings.spectrumPeakReadout && frame.spectrumPeak
@@ -410,7 +464,7 @@ ftxui::Element renderSpectrumPanel(const DisplayFrame& frame,
focused,
detail),
vbox(std::move(spectrumElements)));
return stylePanel(std::move(panel), focused) |
return stylePanel(std::move(panel), focused, PanelId::Spectrum) |
size(WIDTH, EQUAL, std::max(1, width)) |
size(HEIGHT, EQUAL, std::max(1, height));
}
@@ -434,17 +488,21 @@ ftxui::Element renderOscilloscopePanel(const DisplayFrame& frame,
auto plot = canvas([
samples = frame.oscilloscope.samples,
signalPresent = frame.oscilloscope.signalPresent,
traceWeight = settings.oscilloscopeTraceWeight
traceWeight = settings.oscilloscopeTraceWeight,
guideColor = terminalColor(palette().oscilloscopeGuides),
lineColor = terminalColor(palette().oscilloscopeLine),
backgroundColor = terminalColor(palette().oscilloscopeBackground)
](Canvas& surface) {
const int canvasWidth = surface.width();
const int canvasHeight = surface.height();
if (canvasWidth <= 0 || canvasHeight <= 0) {
return;
}
fillCanvasBackground(surface, backgroundColor);
const int centerY = oscilloscopeZeroY(canvasHeight);
surface.DrawPointLine(
0, centerY, canvasWidth - 1, centerY, Color::GrayDark);
0, centerY, canvasWidth - 1, centerY, guideColor);
if (!signalPresent) {
return;
}
@@ -457,7 +515,7 @@ ftxui::Element renderOscilloscopePanel(const DisplayFrame& frame,
std::clamp(points[index - 1].y + thickness, 0, canvasHeight - 1),
points[index].x,
std::clamp(points[index].y + thickness, 0, canvasHeight - 1),
Color::CyanLight);
lineColor);
}
}
}) | flex;
@@ -465,12 +523,15 @@ ftxui::Element renderOscilloscopePanel(const DisplayFrame& frame,
auto panel = window(
panelTitle(PanelId::Oscilloscope, focused, detail),
std::move(plot));
return stylePanel(std::move(panel), focused) |
return stylePanel(std::move(panel), focused, PanelId::Oscilloscope) |
size(WIDTH, EQUAL, std::max(1, width)) |
size(HEIGHT, EQUAL, std::max(1, height));
}
void drawVectorscopeGrid(ftxui::Canvas& surface, VectorscopeMode mode) {
void drawVectorscopeGrid(ftxui::Canvas& surface,
VectorscopeMode mode,
const ftxui::Color& grid,
const ftxui::Color& guide) {
using ftxui::Color;
const auto layout = getVectorscopePlotLayout(
surface.width(), surface.height(), mode);
@@ -478,8 +539,6 @@ void drawVectorscopeGrid(ftxui::Canvas& surface, VectorscopeMode mode) {
return;
}
const auto grid = Color::RGB(76, 82, 88);
const auto guide = Color::RGB(48, 53, 58);
const int left = layout.centerX - layout.radius;
const int right = layout.centerX + layout.radius;
const int top = layout.centerY - layout.radius;
@@ -577,16 +636,21 @@ ftxui::Element renderVectorscopePanel(const DisplayFrame& frame,
pointCount = frame.vectorscope.pointCount,
mode,
showGuides = settings.vectorscopeGuides,
densityDivisor
densityDivisor,
gridColor = terminalColor(palette().vectorscopeGuides),
guideColor = terminalColor(palette().vectorscopeGuidesSecondary),
bandColors = palette().vectorscopeBands,
backgroundColor = terminalColor(palette().vectorscopeBackground)
](Canvas& surface) {
const int canvasWidth = surface.width();
const int canvasHeight = surface.height();
if (canvasWidth <= 0 || canvasHeight <= 0) {
return;
}
fillCanvasBackground(surface, backgroundColor);
if (showGuides) {
drawVectorscopeGrid(surface, mode);
drawVectorscopeGrid(surface, mode, gridColor, guideColor);
}
const auto bands = buildVectorscopePlot(
@@ -597,24 +661,14 @@ ftxui::Element renderVectorscopePanel(const DisplayFrame& frame,
mode,
densityDivisor);
constexpr int ageBuckets = 8;
const std::array<std::array<int, 3>, 3> baseColors = {{
{{255, 68, 68}},
{{68, 221, 68}},
{{68, 136, 255}},
}};
std::array<std::array<Color, ageBuckets>, 3> colors;
for (size_t band = 0; band < colors.size(); ++band) {
for (int bucket = 0; bucket < ageBuckets; ++bucket) {
const float brightness = 0.3f + 0.7f *
static_cast<float>(bucket + 1) /
static_cast<float>(ageBuckets);
colors[band][bucket] = Color::RGB(
static_cast<uint8_t>(std::lround(
static_cast<float>(baseColors[band][0]) * brightness)),
static_cast<uint8_t>(std::lround(
static_cast<float>(baseColors[band][1]) * brightness)),
static_cast<uint8_t>(std::lround(
static_cast<float>(baseColors[band][2]) * brightness)));
colors[band][bucket] = terminalColor(
scaleColor(bandColors[band], brightness));
}
}
for (int bucket = 0; bucket < ageBuckets; ++bucket) {
@@ -640,7 +694,7 @@ ftxui::Element renderVectorscopePanel(const DisplayFrame& frame,
focused,
vectorscopeModeName(mode)),
std::move(plot));
return stylePanel(std::move(panel), focused) |
return stylePanel(std::move(panel), focused, PanelId::Vectorscope) |
size(WIDTH, EQUAL, std::max(1, width)) |
size(HEIGHT, EQUAL, std::max(1, height));
}
@@ -677,12 +731,14 @@ ftxui::Element renderClassicVuGauge(float levelDb,
if (showPeak && column == peakColumn) {
const bool hot = dbfsToClassicVu(peakDb, referenceDbfs) > 0.0f;
cells.push_back(text("") | color(
hot ? Color::RedLight : Color::CyanLight));
terminalColor(hot ? palette().vuClip : palette().vuPeak)));
} else if (column < levelColumns) {
cells.push_back(text("") | color(
column >= hotColumn ? Color::Red : Color::Cyan));
terminalColor(column >= hotColumn
? palette().vuClip
: palette().vuLevel)));
} else {
cells.push_back(text("·") | color(Color::GrayDark));
cells.push_back(text("·") | color(terminalColor(palette().vuTrack)));
}
}
return hbox(std::move(cells)) | size(WIDTH, EQUAL, resolvedColumns);
@@ -703,13 +759,13 @@ ftxui::Element renderCorrelationGauge(float correlation, int columns) {
const bool negativeFill = clamped < 0.0f &&
column < center && column >= center - extent;
if (column == center) {
cells.push_back(text("") | color(Color::GrayLight));
cells.push_back(text("") | color(terminalColor(palette().vuScale)));
} else if (positiveFill) {
cells.push_back(text("") | color(Color::Cyan));
cells.push_back(text("") | color(terminalColor(palette().vuLevel)));
} else if (negativeFill) {
cells.push_back(text("") | color(Color::Red));
cells.push_back(text("") | color(terminalColor(palette().vuClip)));
} else {
cells.push_back(text("·") | color(Color::GrayDark));
cells.push_back(text("·") | color(terminalColor(palette().vuTrack)));
}
}
return hbox({
@@ -780,9 +836,11 @@ ftxui::Element renderVerticalVuBar(float levelDb,
const bool peakHere = peak > bottom && peak <= top;
const bool filled = level > bottom;
const bool hot = bottom >= classicVuToNormalized(0.0f);
if (peakHere) return text("━━") | color(hot ? Color::RedLight : Color::CyanLight);
if (filled) return text("██") | color(hot ? Color::Red : Color::Cyan);
return text("··") | color(Color::GrayDark);
if (peakHere) return text("━━") | color(terminalColor(
hot ? palette().vuClip : palette().vuPeak));
if (filled) return text("██") | color(terminalColor(
hot ? palette().vuClip : palette().vuLevel));
return text("··") | color(terminalColor(palette().vuTrack));
}
ftxui::Element renderVerticalVu(const DisplayFrame& frame,
@@ -830,11 +888,21 @@ ftxui::Element renderNeedleVu(const DisplayFrame& frame,
combinedDb,
combinedPeak,
combined,
referenceDbfs
referenceDbfs,
scaleColor = terminalColor(palette().vuScale),
trackColor = terminalColor(palette().vuTrack),
clipColor = terminalColor(palette().vuClip),
levelColor = terminalColor(palette().vuLevel),
leftColor = terminalColor(palette().vuNeedleLeft),
rightColor = terminalColor(palette().vuNeedleRight),
combinedColor = terminalColor(palette().vuNeedleCombined),
backgroundColor = terminalColor(palette().vuBackground)
](Canvas& surface) {
constexpr float pi = 3.14159265358979323846f;
const int canvasWidth = surface.width();
const int canvasHeight = surface.height();
if (canvasWidth <= 0 || canvasHeight <= 0) return;
fillCanvasBackground(surface, backgroundColor);
if (canvasWidth < 8 || canvasHeight < 8) return;
const float startAngle = pi * 1.08f;
const float endAngle = pi * 1.92f;
@@ -882,12 +950,12 @@ ftxui::Element renderNeedleVu(const DisplayFrame& frame,
peakInner.first, peakInner.second,
peakOuter.first, peakOuter.second,
dbfsToClassicVu(peak, referenceDbfs) > 0.0f
? Color::RedLight
? clipColor
: color);
};
drawArc(startAngle, endAngle, 1.0f, Color::GrayDark);
if (!combined) drawArc(startAngle, endAngle, 0.78f, Color::RGB(54, 64, 68));
drawArc(startAngle, endAngle, 1.0f, trackColor);
if (!combined) drawArc(startAngle, endAngle, 0.78f, trackColor);
const std::array<float, 9> ticks = {
-20.0f, -10.0f, -5.0f, -3.0f, -1.0f, 0.0f, 1.0f, 2.0f, 3.0f};
for (float vu : ticks) {
@@ -898,22 +966,23 @@ ftxui::Element renderNeedleVu(const DisplayFrame& frame,
surface.DrawPointLine(
inner.first, inner.second,
outer.first, outer.second,
vu >= 0.0f ? Color::Red : Color::GrayLight);
vu >= 0.0f ? clipColor : scaleColor);
}
const float hotAngle = startAngle + classicVuToNormalized(0.0f) *
(endAngle - startAngle);
drawArc(hotAngle, endAngle, 1.0f, Color::Red);
drawArc(hotAngle, endAngle, 1.0f, clipColor);
if (combined) {
drawArc(startAngle, angleForDb(combinedDb), 1.0f, Color::Cyan);
drawNeedle(combinedDb, combinedPeak, 1.0f, Color::CyanLight);
drawArc(startAngle, angleForDb(combinedDb), 1.0f, levelColor);
drawNeedle(combinedDb, combinedPeak, 1.0f, combinedColor);
} else {
drawArc(startAngle, angleForDb(left), 0.78f, Color::BlueLight);
drawArc(startAngle, angleForDb(right), 1.0f, Color::Cyan);
drawNeedle(left, leftPeak, 0.78f, Color::BlueLight);
drawNeedle(right, rightPeak, 1.0f, Color::CyanLight);
drawArc(startAngle, angleForDb(left), 0.78f, leftColor);
drawArc(startAngle, angleForDb(right), 1.0f, rightColor);
drawNeedle(left, leftPeak, 0.78f, leftColor);
drawNeedle(right, rightPeak, 1.0f, rightColor);
}
surface.DrawPointCircleFilled(centerX, centerY, 1, Color::CyanLight);
surface.DrawPointCircleFilled(
centerX, centerY, 1, combined ? combinedColor : levelColor);
}) | size(HEIGHT, EQUAL, plotRows) | flex;
const std::string readings = combined
@@ -922,7 +991,7 @@ ftxui::Element renderNeedleVu(const DisplayFrame& frame,
formatDb(frame.vu.vuRDb) + " dB R";
return vbox({
std::move(face),
text(readings) | center | color(Color::CyanLight),
text(readings) | center | color(terminalColor(palette().vuLabels)),
renderCorrelationGauge(
frame.vu.correlation, std::max(5, contentWidth - 8)) | center,
});
@@ -959,7 +1028,7 @@ ftxui::Element renderVUMeterPanel(const DisplayFrame& frame,
auto panel = window(
panelTitle(PanelId::VUMeter, focused, detail),
std::move(body));
return stylePanel(std::move(panel), focused) |
return stylePanel(std::move(panel), focused, PanelId::VUMeter) |
size(WIDTH, EQUAL, std::max(1, width)) |
size(HEIGHT, EQUAL, std::max(1, height));
}
@@ -999,13 +1068,13 @@ ftxui::Element renderLufsBarCell(float levelDb,
return result;
};
if (targetRow) return text(repeat("")) |
color(Color::RedLight);
color(terminalColor(palette().lufsTarget));
if (peakHere) return text(repeat("")) |
color(Color::CyanLight);
color(terminalColor(palette().lufsLabels));
if (filled) return text(repeat("")) |
color(Color::Cyan);
color(terminalColor(palette().lufsLevel));
return text(repeat("·")) |
color(Color::GrayDark);
color(terminalColor(palette().lufsTrack));
}
ftxui::Element renderLUFSMeterPanel(const DisplayFrame& frame,
@@ -1033,14 +1102,14 @@ ftxui::Element renderLUFSMeterPanel(const DisplayFrame& frame,
rows.push_back(hbox({
text(" L R LUFS") | dim,
filler(),
text("target -14") | color(Color::RedLight) | dim,
text("target -14") | color(terminalColor(palette().lufsTarget)),
}));
for (int row = 0; row < meterRows; ++row) {
Elements parts;
const auto gap = [&]() {
auto element = text(row == targetRow ? "" : " ");
return row == targetRow
? element | color(Color::RedLight)
? element | color(terminalColor(palette().lufsTarget))
: element;
};
parts.push_back(text(lufsScaleLabel(row, meterRows)) | dim);
@@ -1060,7 +1129,8 @@ ftxui::Element renderLUFSMeterPanel(const DisplayFrame& frame,
if (row == selectedRow) {
parts.push_back(
text(" " + formatLufs(selected) + " LUFS ") |
bgcolor(Color::Cyan) | color(Color::Black) | bold);
bgcolor(terminalColor(palette().lufsLevel)) |
color(terminalColor(palette().lufsBackground)) | bold);
}
rows.push_back(hbox(std::move(parts)));
}
@@ -1074,36 +1144,39 @@ ftxui::Element renderLUFSMeterPanel(const DisplayFrame& frame,
auto panel = window(
panelTitle(PanelId::LUFSMeter, focused, detail),
vbox(std::move(rows)));
return stylePanel(std::move(panel), focused) |
return stylePanel(std::move(panel), focused, PanelId::LUFSMeter) |
size(WIDTH, EQUAL, std::max(1, width)) |
size(HEIGHT, EQUAL, std::max(1, height));
}
ftxui::Color interpolateColor(const std::array<int, 3>& from,
const std::array<int, 3>& to,
ftxui::Color interpolateColor(const ThemeColor& from,
const ThemeColor& to,
float amount) {
const float t = std::clamp(amount, 0.0f, 1.0f);
return ftxui::Color::RGB(
static_cast<uint8_t>(std::lround(from[0] + (to[0] - from[0]) * t)),
static_cast<uint8_t>(std::lround(from[1] + (to[1] - from[1]) * t)),
static_cast<uint8_t>(std::lround(from[2] + (to[2] - from[2]) * t)));
static_cast<uint8_t>(std::lround(from.red +
(static_cast<int>(to.red) - from.red) * t)),
static_cast<uint8_t>(std::lround(from.green +
(static_cast<int>(to.green) - from.green) * t)),
static_cast<uint8_t>(std::lround(from.blue +
(static_cast<int>(to.blue) - from.blue) * t)));
}
ftxui::Color spectrogramColor(float intensity, SpectrogramColorMode mode) {
const float value = std::clamp(intensity, 0.0f, 1.0f);
if (mode == SpectrogramColorMode::Mono) {
return interpolateColor(
{{5, 12, 14}}, {{102, 255, 255}}, std::pow(value, 0.72f));
palette().spectrogramBackground,
palette().spectrogramMono,
std::pow(value, 0.72f));
}
constexpr std::array<float, 6> stops = {0.0f, 0.16f, 0.36f, 0.58f, 0.78f, 1.0f};
constexpr std::array<std::array<int, 3>, 6> colors = {{
{{5, 3, 12}},
{{15, 7, 33}},
{{61, 11, 94}},
{{163, 26, 121}},
{{255, 82, 87}},
{{255, 241, 209}},
constexpr std::array<float, 4> stops = {0.0f, 0.20f, 0.62f, 1.0f};
const std::array<ThemeColor, 4> colors = {{
palette().spectrogramBackground,
palette().spectrogramHeat[0],
palette().spectrogramHeat[1],
palette().spectrogramHeat[2],
}};
size_t upper = 1;
while (upper + 1 < stops.size() && value > stops[upper]) ++upper;
@@ -1276,8 +1349,10 @@ ftxui::Element renderSpectrogramPanel(const DisplayFrame& frame,
history,
colorMode = settings.spectrogramColor,
clarity = settings.spectrogramClarity,
vertical
vertical,
backgroundColor = terminalColor(palette().spectrogramBackground)
](Canvas& surface) {
fillCanvasBackground(surface, backgroundColor);
if (surface.width() <= 0 || surface.height() <= 0 ||
history.columnCount == 0 ||
history.columnStride == 0) {
@@ -1307,21 +1382,22 @@ ftxui::Element renderSpectrogramPanel(const DisplayFrame& frame,
auto panel = window(
panelTitle(PanelId::Spectrogram, focused, detail),
std::move(plot));
return stylePanel(std::move(panel), focused) |
return stylePanel(std::move(panel), focused, PanelId::Spectrogram) |
size(WIDTH, EQUAL, std::max(1, width)) |
size(HEIGHT, EQUAL, std::max(1, height));
}
ftxui::Color waveformBandColor(const float* summary, bool multiband) {
using namespace ftxui;
if (!multiband || summary == nullptr) return Color::CyanLight;
if (!multiband || summary == nullptr) {
return terminalColor(palette().waveformLine);
}
std::array<float, 3> weights = {
std::max(0.0f, summary[2]),
std::max(0.0f, summary[3]),
std::max(0.0f, summary[4]),
};
float total = weights[0] + weights[1] + weights[2];
if (total <= 1.0e-8f) return Color::CyanLight;
if (total <= 1.0e-8f) return terminalColor(palette().waveformLine);
for (float& weight : weights) {
weight = std::pow(weight / total, 2.6f);
}
@@ -1329,19 +1405,18 @@ ftxui::Color waveformBandColor(const float* summary, bool multiband) {
for (float& weight : weights) {
weight /= std::max(total, 1.0e-8f);
}
constexpr std::array<std::array<int, 3>, 3> colors = {{
{{255, 68, 68}},
{{68, 221, 68}},
{{68, 136, 255}},
}};
std::array<int, 3> mixed{};
for (size_t channel = 0; channel < mixed.size(); ++channel) {
for (size_t band = 0; band < weights.size(); ++band) {
const auto& color = palette().waveformBands[band];
const uint8_t value = channel == 0
? color.red
: channel == 1 ? color.green : color.blue;
mixed[channel] += static_cast<int>(std::lround(
weights[band] * static_cast<float>(colors[band][channel])));
weights[band] * static_cast<float>(value)));
}
}
return Color::RGB(
return ftxui::Color::RGB(
std::clamp(mixed[0], 0, 255),
std::clamp(mixed[1], 0, 255),
std::clamp(mixed[2], 0, 255));
@@ -1357,11 +1432,15 @@ ftxui::Element renderWaveformPanel(const DisplayFrame& frame,
auto plot = canvas([
history = frame.waveform.history,
stereo,
multiband = settings.waveformMultiband
multiband = settings.waveformMultiband,
guideColor = terminalColor(palette().waveformGuides),
secondaryGuideColor = terminalColor(palette().waveformGuidesSecondary),
backgroundColor = terminalColor(palette().waveformBackground)
](Canvas& surface) {
const int canvasWidth = surface.width();
const int canvasHeight = surface.height();
if (canvasWidth <= 0 || canvasHeight <= 0) return;
fillCanvasBackground(surface, backgroundColor);
const int laneCount = stereo ? 2 : 1;
for (int lane = 0; lane < laneCount; ++lane) {
@@ -1370,12 +1449,12 @@ ftxui::Element renderWaveformPanel(const DisplayFrame& frame,
static_cast<float>(canvasHeight) / static_cast<float>(laneCount)));
surface.DrawPointLine(
0, centerY, canvasWidth - 1, centerY,
Color::RGB(55, 61, 64));
guideColor);
}
if (stereo) {
surface.DrawPointLine(
0, canvasHeight / 2, canvasWidth - 1, canvasHeight / 2,
Color::RGB(40, 45, 48));
secondaryGuideColor);
}
if (history.columnCount == 0 ||
@@ -1422,7 +1501,7 @@ ftxui::Element renderWaveformPanel(const DisplayFrame& frame,
auto panel = window(
panelTitle(PanelId::Waveform, focused, detail),
std::move(plot));
return stylePanel(std::move(panel), focused) |
return stylePanel(std::move(panel), focused, PanelId::Waveform) |
size(WIDTH, EQUAL, std::max(1, width)) |
size(HEIGHT, EQUAL, std::max(1, height));
}
@@ -1538,18 +1617,20 @@ ftxui::Element renderHeader(const DashboardLayout& layout,
: profileDirty ? " • Working *" : "";
Element profileElement = width >= 90 && !profileLabel.empty()
? text(profileLabel) |
(profileDirty ? color(Color::YellowLight) : dim) |
(profileDirty ? color(terminalColor(palette().warning)) : dim) |
size(WIDTH, LESS_THAN, 32)
: emptyElement();
return hbox({
text(" PRISM") | color(Color::CyanLight) | bold,
text(" PRISM") | color(terminalColor(palette().accent)) | bold,
text(" TUI") | bold,
std::move(profileElement),
filler(),
state.expandedPanel
? text("FOCUS • " + panelName(*state.expandedPanel) + " ") | color(Color::CyanLight)
? text("FOCUS • " + panelName(*state.expandedPanel) + " ") |
color(terminalColor(palette().accent))
: state.layoutEditing
? text(rackStatus.str() + " ") | color(Color::CyanLight) | bold
? text(rackStatus.str() + " ") |
color(terminalColor(palette().accent)) | bold
: text(rackStatus.str() + " ") | dim,
});
}
@@ -1565,12 +1646,14 @@ ftxui::Element renderFooter(const DisplayFrame& frame,
if (!state.layoutStatus.empty()) {
if (width < 80) {
return text(" " + state.layoutStatus) |
color(Color::CyanLight);
color(terminalColor(palette().accent));
}
return hbox({
text(" " + state.layoutStatus) | color(Color::CyanLight) | bold,
text(" " + state.layoutStatus) |
color(terminalColor(palette().accent)) | bold,
filler(),
text(essentialControls + " ") | color(Color::GrayLight),
text(essentialControls + " ") |
color(terminalColor(palette().muted)),
});
}
const std::string controls = width < 64
@@ -1580,7 +1663,8 @@ ftxui::Element renderFooter(const DisplayFrame& frame,
: width < 160
? "arrows select • Shift+arrows move • [ ] width • a add • x remove • ? help • Enter done"
: "arrows select • Shift+arrows move • [] width • ,. height • n new row • a add • x remove • ? help • Enter done";
return text(controls + " ") | color(Color::CyanLight) | align_right;
return text(controls + " ") |
color(terminalColor(palette().accent)) | align_right;
}
const std::string enterAction = state.expandedPanel ? "restore" : "expand";
const std::string controls = minimal
@@ -1592,7 +1676,7 @@ ftxui::Element renderFooter(const DisplayFrame& frame,
" • p profiles • s settings • l edit layout • r reset • q quit";
auto status = text(makeCaptureStatus(frame, state.settings, compact)) | dim;
if (frame.captureOverrun) {
status = status | color(Color::RedLight);
status = status | color(terminalColor(palette().danger));
}
return hbox({
status,
@@ -1613,10 +1697,10 @@ ftxui::Element settingsRow(const std::string& label,
text(" "),
});
if (selected) {
row = row | color(Color::CyanLight) | bold |
bgcolor(Color::RGB(24, 42, 46));
row = row | color(terminalColor(palette().accent)) | bold |
bgcolor(terminalColor(palette().selection));
} else {
row = row | color(Color::GrayLight);
row = row | color(terminalColor(palette().text));
}
return row | size(HEIGHT, EQUAL, 1);
}
@@ -1633,7 +1717,7 @@ ftxui::Element renderLayoutAddScope(const InterfaceState& state,
rows.push_back(filler());
rows.push_back(
text("All seven scopes are already in the rack.") |
color(Color::GrayLight) | center);
color(terminalColor(palette().muted)) | center);
rows.push_back(filler());
} else {
const size_t selected = std::min(
@@ -1661,7 +1745,7 @@ ftxui::Element renderLayoutAddScope(const InterfaceState& state,
}
auto content = vbox({
text(" PRISM / EDIT LAYOUT / ADD SCOPE") |
color(Color::CyanLight) | bold,
color(terminalColor(palette().accent)) | bold,
separator(),
removed.empty()
? text("Nothing to restore.") | dim
@@ -1703,12 +1787,14 @@ ftxui::Element renderLayoutHelp(int width, int height) {
}
auto content = vbox({
text(" PRISM / EDIT LAYOUT / HELP") | color(Color::CyanLight) | bold,
text(" PRISM / EDIT LAYOUT / HELP") |
color(terminalColor(palette().accent)) | bold,
separator(),
vbox(std::move(instructions)),
filler(),
contentHeight >= 12
? text("Changes are saved immediately.") | color(Color::GrayLight)
? text("Changes are saved immediately.") |
color(terminalColor(palette().muted))
: emptyElement(),
separator(),
text("? / Esc back") | dim,
@@ -1776,16 +1862,16 @@ ftxui::Element renderSettings(const InterfaceState& state,
? "↑↓ select • ←→ adjust • Enter • Esc back"
: "↑↓ select • ←→ adjust • Enter toggle • Backspace default • Esc back";
auto content = vbox({
text(breadcrumb) | color(Color::CyanLight) | bold,
text(breadcrumb) | color(terminalColor(palette().accent)) | bold,
separator(),
text(settingsPageDescription(state.settingsPage)) | dim,
separatorEmpty(),
vbox(std::move(rows)),
filler(),
text(selectedDescription) | color(Color::GrayLight),
text(selectedDescription) | color(terminalColor(palette().muted)),
state.settingsStatus.empty()
? emptyElement()
: text(state.settingsStatus) | color(Color::RedLight),
: text(state.settingsStatus) | color(terminalColor(palette().danger)),
separator(),
text(controls) | dim,
}) | size(WIDTH, EQUAL, contentWidth) |
@@ -1826,17 +1912,19 @@ ftxui::Element renderProfiles(const InterfaceState& state,
auto row = hbox({
text(isSelected ? " " : " "),
text(isActive ? "" : " ") |
color(isActive ? Color::CyanLight : Color::GrayDark),
color(terminalColor(
isActive ? palette().accent : palette().muted)),
text(profile.name) | (isSelected ? bold : dim),
isActive && dirty
? text(" * modified") | color(Color::YellowLight)
? text(" * modified") |
color(terminalColor(palette().warning))
: emptyElement(),
filler(),
profile.isDefault ? text("default ") | dim : emptyElement(),
});
rows.push_back(isSelected
? std::move(row) | bgcolor(Color::RGB(18, 49, 52)) |
color(Color::CyanLight)
? std::move(row) | bgcolor(terminalColor(palette().selection)) |
color(terminalColor(palette().accent))
: std::move(row));
}
const std::string activeDescription = active
@@ -1845,19 +1933,22 @@ ftxui::Element renderProfiles(const InterfaceState& state,
? "Working setup is not saved to a profile."
: "No active profile.";
auto content = vbox({
text(" PRISM / PROFILES") | color(Color::CyanLight) | bold,
text(" PRISM / PROFILES") |
color(terminalColor(palette().accent)) | bold,
separator(),
text(activeDescription) |
(dirty ? color(Color::YellowLight) : color(Color::GrayLight)),
(dirty
? color(terminalColor(palette().warning))
: color(terminalColor(palette().text))),
separatorEmpty(),
vbox(std::move(rows)),
filler(),
state.profileStatus.empty()
? emptyElement()
: text(state.profileStatus) | color(
state.profileStatusError
? Color::RedLight
: Color::CyanLight),
terminalColor(state.profileStatusError
? palette().danger
: palette().accent)),
separator(),
text(contentWidth < 60
? "↑↓ select • Enter load • n save • Esc"
@@ -1884,7 +1975,7 @@ ftxui::Element renderProfiles(const InterfaceState& state,
action = hbox({
text(" Name ") | dim,
text(state.profileInput.empty() ? " " : state.profileInput) | bold,
text("") | color(Color::CyanLight),
text("") | color(terminalColor(palette().accent)),
}) | border;
break;
case ProfileOverlayMode::Rename:
@@ -1895,7 +1986,7 @@ ftxui::Element renderProfiles(const InterfaceState& state,
action = hbox({
text(" Name ") | dim,
text(state.profileInput.empty() ? " " : state.profileInput) | bold,
text("") | color(Color::CyanLight),
text("") | color(terminalColor(palette().accent)),
}) | border;
break;
case ProfileOverlayMode::ConfirmOverwrite:
@@ -1904,14 +1995,15 @@ ftxui::Element renderProfiles(const InterfaceState& state,
? "Replace “" + active->name + "” with the current setup?"
: "There is no active profile to overwrite.";
action = text("This changes the saved .prsmt file.") |
color(Color::YellowLight);
color(terminalColor(palette().warning));
break;
case ProfileOverlayMode::ConfirmDelete:
title = " PRISM / PROFILES / DELETE";
message = selected
? "Delete “" + selected->name + "”?"
: "Choose a profile to delete.";
action = text("This cannot be undone.") | color(Color::RedLight);
action = text("This cannot be undone.") |
color(terminalColor(palette().danger));
controls = "d confirm delete • Esc cancel";
break;
case ProfileOverlayMode::ConfirmLoad:
@@ -1923,22 +2015,22 @@ ftxui::Element renderProfiles(const InterfaceState& state,
text("w save active profile, then load"),
text("n save as a new profile, then load"),
text("d discard changes and load"),
}) | color(Color::YellowLight)
}) | color(terminalColor(palette().warning))
: vbox({
text("n save as a new profile, then load"),
text("d discard changes and load"),
}) | color(Color::YellowLight)
}) | color(terminalColor(palette().warning))
: text(active
? "w save & load • n save as & load • d discard & load"
: "n save as & load • d discard & load") |
color(Color::YellowLight);
color(terminalColor(palette().warning));
controls = "Choose an action • Esc cancel";
break;
case ProfileOverlayMode::Browse:
break;
}
auto content = vbox({
text(title) | color(Color::CyanLight) | bold,
text(title) | color(terminalColor(palette().accent)) | bold,
separator(),
text(message),
separatorEmpty(),
@@ -1947,9 +2039,9 @@ ftxui::Element renderProfiles(const InterfaceState& state,
state.profileStatus.empty()
? emptyElement()
: text(state.profileStatus) | color(
state.profileStatusError
? Color::RedLight
: Color::CyanLight),
terminalColor(state.profileStatusError
? palette().danger
: palette().accent)),
separator(),
text(controls) | dim,
}) | size(WIDTH, EQUAL, contentWidth) |
@@ -1969,7 +2061,8 @@ ftxui::Element renderFrame(const DisplayFrame& frame,
if (layout.terminalTooSmall) {
return vbox({
filler(),
text("PRISM TUI") | bold | color(Color::CyanLight) | center,
text("PRISM TUI") | bold |
color(terminalColor(palette().accent)) | center,
text("Terminal too small — need at least 44 × 12") | center,
text("q quit") | dim | center,
filler(),
@@ -2038,6 +2131,17 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
InterfaceState interfaceState;
const std::filesystem::path settingsPath = defaultSettingsPath();
interfaceState.settings = loadSettings(settingsPath);
IroThemeLibrary themeLibrary(defaultIroThemeDirectory());
std::string themeLoadWarning;
if (!themeLibrary.load(&themeLoadWarning)) {
interfaceState.settingsStatus = themeLoadWarning;
} else if (!themeLoadWarning.empty()) {
interfaceState.settingsStatus = themeLoadWarning;
}
if (!themeLibrary.find(interfaceState.settings.themeId)) {
interfaceState.settings.themeId = "Default";
}
interfaceState.theme = themeLibrary.resolve(interfaceState.settings.themeId);
TuiProfileLibrary profileLibrary(
defaultProfileDirectory(), defaultProfileStatePath());
std::string profileLoadError;
@@ -2152,11 +2256,18 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
});
auto renderer = Renderer([&]() {
renderTheme = &interfaceState.theme;
return renderFrame(
frameStore.read(), screen.dimx(), screen.dimy(), interfaceState);
frameStore.read(), screen.dimx(), screen.dimy(), interfaceState) |
color(terminalColor(interfaceState.theme.text)) |
bgcolor(terminalColor(interfaceState.theme.background));
});
const auto persistSettings = [&]() {
interfaceState.settings = normalizeSettings(interfaceState.settings);
if (!themeLibrary.find(interfaceState.settings.themeId)) {
interfaceState.settings.themeId = "Default";
}
interfaceState.theme = themeLibrary.resolve(interfaceState.settings.themeId);
interfaceState.profileDirty =
calculateUnsavedProfileChanges(interfaceState);
settingsStore.publish(interfaceState.settings);
@@ -2864,10 +2975,18 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> capture,
(event == Event::ArrowLeft || event == Event::ArrowRight ||
event == Event::Return)) {
const int direction = event == Event::ArrowLeft ? -1 : 1;
if (adjustSetting(
interfaceState.settings,
pageSettings[selected].id,
direction)) {
const SettingId setting = pageSettings[selected].id;
bool changed = false;
if (setting == SettingId::Theme) {
const std::string nextTheme = themeLibrary.adjacentId(
interfaceState.settings.themeId, direction);
changed = nextTheme != interfaceState.settings.themeId;
interfaceState.settings.themeId = nextTheme;
} else {
changed = adjustSetting(
interfaceState.settings, setting, direction);
}
if (changed) {
persistSettings();
}
return true;
+24 -3
View File
@@ -13,6 +13,10 @@
namespace Prism::Tui {
namespace {
const std::vector<SettingDescriptor> kAppearanceSettings = {
{SettingId::Theme, "Theme", "Uses Prism .iro files from the shared Prism Themes folder."},
};
const std::vector<SettingDescriptor> kGeneralSettings = {
{SettingId::InputTrim, "Input trim", "Applies gain before every analyzer."},
{SettingId::RefreshRate, "Refresh rate", "Controls how often the terminal display is published."},
@@ -295,6 +299,11 @@ const char* waveformModeName(WaveformMode mode) {
}
TuiSettings normalizeSettings(TuiSettings settings) {
settings.themeId.erase(std::remove_if(
settings.themeId.begin(), settings.themeId.end(), [](char character) {
return character == '\n' || character == '\r';
}), settings.themeId.end());
if (settings.themeId.empty()) settings.themeId = "Default";
settings.inputTrimDb = std::clamp(snap(settings.inputTrimDb, 0.5f), -12.0f, 12.0f);
settings.refreshRate = settings.refreshRate <= 30 ? 30 : 60;
settings.rackLayout = normalizeRackLayout(std::move(settings.rackLayout));
@@ -317,7 +326,8 @@ TuiSettings normalizeSettings(TuiSettings settings) {
}
bool operator==(const TuiSettings& left, const TuiSettings& right) {
return left.inputTrimDb == right.inputTrimDb &&
return left.themeId == right.themeId &&
left.inputTrimDb == right.inputTrimDb &&
left.refreshRate == right.refreshRate &&
left.rackLayout == right.rackLayout &&
left.spectrumPeakReadout == right.spectrumPeakReadout &&
@@ -351,6 +361,7 @@ bool operator!=(const TuiSettings& left, const TuiSettings& right) {
std::vector<SettingsPage> settingsPages() {
return {
SettingsPage::Appearance,
SettingsPage::General,
SettingsPage::Spectrum,
SettingsPage::Oscilloscope,
@@ -365,6 +376,7 @@ std::vector<SettingsPage> settingsPages() {
const char* settingsPageName(SettingsPage page) {
switch (page) {
case SettingsPage::Home: return "Settings";
case SettingsPage::Appearance: return "Appearance";
case SettingsPage::General: return "General";
case SettingsPage::Spectrum: return "Spectrum";
case SettingsPage::Oscilloscope: return "Oscilloscope";
@@ -380,6 +392,7 @@ const char* settingsPageName(SettingsPage page) {
const char* settingsPageDescription(SettingsPage page) {
switch (page) {
case SettingsPage::Home: return "Choose a section.";
case SettingsPage::Appearance: return "Shared Prism .iro colors for the terminal dashboard.";
case SettingsPage::General: return "Audio input and dashboard behavior.";
case SettingsPage::Spectrum: return "Frequency analysis and readouts.";
case SettingsPage::Oscilloscope: return "Waveform stabilization and presentation.";
@@ -394,6 +407,7 @@ const char* settingsPageDescription(SettingsPage page) {
const std::vector<SettingDescriptor>& settingsForPage(SettingsPage page) {
switch (page) {
case SettingsPage::Appearance: return kAppearanceSettings;
case SettingsPage::General: return kGeneralSettings;
case SettingsPage::Spectrum: return kSpectrumSettings;
case SettingsPage::Oscilloscope: return kOscilloscopeSettings;
@@ -410,6 +424,8 @@ const std::vector<SettingDescriptor>& settingsForPage(SettingsPage page) {
std::string settingValue(const TuiSettings& settings, SettingId setting) {
switch (setting) {
case SettingId::Theme:
return settings.themeId;
case SettingId::InputTrim:
return (settings.inputTrimDb > 0.0f ? "+" : "") +
trimFloat(settings.inputTrimDb, 1) + " dB";
@@ -481,6 +497,8 @@ bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) {
if (direction == 0) return false;
const TuiSettings before = settings;
switch (setting) {
case SettingId::Theme:
return false;
case SettingId::InputTrim:
settings.inputTrimDb += direction > 0 ? 0.5f : -0.5f;
break;
@@ -604,6 +622,7 @@ bool resetSetting(TuiSettings& settings, SettingId setting) {
const TuiSettings defaults;
const TuiSettings before = settings;
switch (setting) {
case SettingId::Theme: settings.themeId = defaults.themeId; break;
case SettingId::InputTrim: settings.inputTrimDb = defaults.inputTrimDb; break;
case SettingId::RefreshRate: settings.refreshRate = defaults.refreshRate; break;
case SettingId::SpectrumPeakReadout: settings.spectrumPeakReadout = defaults.spectrumPeakReadout; break;
@@ -673,7 +692,8 @@ TuiSettings parseSettingsText(const std::string& text,
if (separator == std::string::npos) continue;
const std::string key = line.substr(0, separator);
const std::string value = line.substr(separator + 1);
if (key == "input_trim_db") settings.inputTrimDb = parseFloat(value, settings.inputTrimDb);
if (key == "theme_id") settings.themeId = value;
else if (key == "input_trim_db") settings.inputTrimDb = parseFloat(value, settings.inputTrimDb);
else if (key == "refresh_rate") settings.refreshRate = parseInt(value, settings.refreshRate);
else if (key == "rack_layout") settings.rackLayout = parseRackLayout(value, settings.rackLayout);
else if (key == "spectrum_peak") settings.spectrumPeakReadout = parseBool(value, settings.spectrumPeakReadout);
@@ -707,7 +727,8 @@ std::string serializeSettingsText(const TuiSettings& rawSettings,
bool includeRefreshRate) {
const TuiSettings settings = normalizeSettings(rawSettings);
std::ostringstream output;
output << "input_trim_db=" << settings.inputTrimDb << '\n';
output << "theme_id=" << settings.themeId << '\n'
<< "input_trim_db=" << settings.inputTrimDb << '\n';
if (includeRefreshRate) {
output << "refresh_rate=" << settings.refreshRate << '\n';
}
+3
View File
@@ -12,6 +12,7 @@ namespace Prism::Tui {
enum class SettingsPage {
Home,
Appearance,
General,
Spectrum,
Oscilloscope,
@@ -23,6 +24,7 @@ enum class SettingsPage {
};
enum class SettingId {
Theme,
InputTrim,
RefreshRate,
SpectrumPeakReadout,
@@ -63,6 +65,7 @@ enum class SpectrogramOrientation { Horizontal, Vertical };
enum class WaveformMode { Mono, Stereo };
struct TuiSettings {
std::string themeId = "Default";
float inputTrimDb = 0.0f;
int refreshRate = 60;
RackLayout rackLayout = defaultRackLayout();
+873
View File
@@ -0,0 +1,873 @@
#include "tui_theme.h"
#include <algorithm>
#include <cctype>
#include <cmath>
#include <cstdlib>
#include <fstream>
#include <iterator>
#include <optional>
#include <sstream>
#include <unordered_map>
#if defined(_WIN32)
#include <windows.h>
#include <knownfolders.h>
#include <shlobj.h>
#endif
namespace Prism::Tui {
namespace {
struct RgbaColor {
float red = 0.0f;
float green = 0.0f;
float blue = 0.0f;
float alpha = 1.0f;
};
using TokenMap = std::unordered_map<std::string, RgbaColor>;
std::string trim(std::string value) {
const auto notSpace = [](unsigned char character) {
return !std::isspace(character);
};
value.erase(value.begin(), std::find_if(value.begin(), value.end(), notSpace));
value.erase(std::find_if(value.rbegin(), value.rend(), notSpace).base(), value.end());
return value;
}
std::string normalizeKey(const std::string& value) {
std::string result;
bool separator = false;
for (const unsigned char character : trim(value)) {
if (std::isalnum(character)) {
if (separator && !result.empty()) result.push_back('_');
result.push_back(static_cast<char>(std::tolower(character)));
separator = false;
} else {
separator = true;
}
}
return result;
}
float clampByte(float value) {
return std::clamp(value, 0.0f, 255.0f);
}
std::optional<float> parseNumber(const std::string& token) {
try {
size_t consumed = 0;
const float value = std::stof(trim(token), &consumed);
if (consumed != trim(token).size() || !std::isfinite(value)) {
return std::nullopt;
}
return value;
} catch (...) {
return std::nullopt;
}
}
std::vector<std::string> split(const std::string& value, char separator) {
std::vector<std::string> parts;
std::istringstream input(value);
std::string part;
while (std::getline(input, part, separator)) parts.push_back(trim(part));
return parts;
}
std::optional<RgbaColor> parseHexColor(const std::string& value) {
if (value.empty() || value.front() != '#') return std::nullopt;
std::string digits = value.substr(1);
if (digits.size() == 3 || digits.size() == 4) {
std::string expanded;
for (const char digit : digits) {
expanded.push_back(digit);
expanded.push_back(digit);
}
digits = expanded;
}
if (digits.size() != 6 && digits.size() != 8) return std::nullopt;
try {
const auto channel = [&](size_t offset) {
return static_cast<float>(std::stoul(digits.substr(offset, 2), nullptr, 16));
};
return RgbaColor{
channel(0), channel(2), channel(4),
digits.size() == 8 ? channel(6) / 255.0f : 1.0f};
} catch (...) {
return std::nullopt;
}
}
std::optional<RgbaColor> parseFunctionColor(const std::string& value) {
const std::string normalized = normalizeKey(
value.substr(0, value.find('(')));
if (normalized != "rgb" && normalized != "rgba") return std::nullopt;
const size_t open = value.find('(');
const size_t close = value.rfind(')');
if (open == std::string::npos || close == std::string::npos || close <= open) {
return std::nullopt;
}
const auto parts = split(value.substr(open + 1, close - open - 1), ',');
if (parts.size() != 3 && parts.size() != 4) return std::nullopt;
const auto red = parseNumber(parts[0]);
const auto green = parseNumber(parts[1]);
const auto blue = parseNumber(parts[2]);
if (!red || !green || !blue) return std::nullopt;
float alpha = 1.0f;
if (parts.size() == 4) {
const auto parsedAlpha = parseNumber(parts[3]);
if (!parsedAlpha) return std::nullopt;
alpha = *parsedAlpha > 1.0f ? *parsedAlpha / 255.0f : *parsedAlpha;
}
return RgbaColor{
clampByte(*red), clampByte(*green), clampByte(*blue),
std::clamp(alpha, 0.0f, 1.0f)};
}
std::optional<RgbaColor> parseChannelColor(const std::string& value) {
const auto parts = split(value, ',');
if (parts.size() != 3 && parts.size() != 4) return std::nullopt;
const auto red = parseNumber(parts[0]);
const auto green = parseNumber(parts[1]);
const auto blue = parseNumber(parts[2]);
if (!red || !green || !blue) return std::nullopt;
float alpha = 1.0f;
if (parts.size() == 4) {
const auto parsedAlpha = parseNumber(parts[3]);
if (!parsedAlpha) return std::nullopt;
alpha = *parsedAlpha / 255.0f;
}
return RgbaColor{
clampByte(*red), clampByte(*green), clampByte(*blue),
std::clamp(alpha, 0.0f, 1.0f)};
}
std::optional<RgbaColor> parseColor(const std::string& rawValue) {
const std::string value = trim(rawValue);
if (const auto color = parseHexColor(value)) return color;
if (const auto color = parseFunctionColor(value)) return color;
return parseChannelColor(value);
}
RgbaColor rgba(float red, float green, float blue, float alpha = 1.0f) {
return {red, green, blue, alpha};
}
RgbaColor withAlpha(RgbaColor color, float alpha) {
color.alpha = std::clamp(alpha, 0.0f, 1.0f);
return color;
}
RgbaColor multiplyAlpha(RgbaColor color, float multiplier) {
color.alpha = std::clamp(color.alpha * multiplier, 0.0f, 1.0f);
return color;
}
ThemeColor flatten(RgbaColor foreground, ThemeColor background) {
const float alpha = std::clamp(foreground.alpha, 0.0f, 1.0f);
const auto channel = [&](float value, uint8_t base) {
return static_cast<uint8_t>(std::lround(std::clamp(
value * alpha + static_cast<float>(base) * (1.0f - alpha),
0.0f,
255.0f)));
};
return {
channel(foreground.red, background.red),
channel(foreground.green, background.green),
channel(foreground.blue, background.blue)};
}
RgbaColor colorOr(const TokenMap& tokens,
const std::string& section,
const std::string& key,
RgbaColor fallback) {
const auto found = tokens.find(section + "." + key);
return found == tokens.end() ? fallback : found->second;
}
bool hasExtensionIro(const std::filesystem::path& path) {
std::string extension = path.extension().string();
std::transform(extension.begin(), extension.end(), extension.begin(),
[](unsigned char character) {
return static_cast<char>(std::tolower(character));
});
return extension == ".iro";
}
std::string environmentValue(const char* name) {
const char* value = std::getenv(name);
return value == nullptr ? std::string{} : std::string(value);
}
} // namespace
TuiTheme defaultTuiTheme() {
TuiTheme theme;
const std::string content = R"iro(
[Theme]
format = prism-theme
version = 2
credit = Prism
[App]
accent = 56, 189, 248
success = 34, 197, 94
warning = 255, 191, 0
danger = 248, 113, 113
background = 0, 0, 0
border = 255, 255, 255, 23
text = 255, 255, 255
text_muted = 255, 255, 255, 107
[Scopes]
guides = 255, 255, 255, 26
[Vectorscope]
band_low = 255, 68, 68
band_mid = 68, 221, 68
band_high = 68, 136, 255
[Spectrogram]
heat_low = 15, 7, 33
heat_mid = 163, 26, 121
heat_high = 255, 241, 209
[VUMeter]
peak = 255, 127, 0
clip = 255, 120, 80, 230
needle_left = 199, 223, 255
needle_right = 255, 71, 126
needle_combined = 244, 248, 255
[Waveform]
band_low = 255, 68, 68
band_mid = 68, 221, 68
band_high = 68, 136, 255
)iro";
std::string ignored;
parseIroThemeText(content, "Default", theme, &ignored);
return theme;
}
namespace {
struct BundledThemeSource {
const char* name;
const char* content;
};
std::vector<TuiTheme> bundledTuiThemes() {
static constexpr std::array<BundledThemeSource, 5> sources = {{
{"Alpha Centauri", R"iro(
[Theme]
format = prism-theme
version = 2
credit = MxnGxzr
description = It exists
[App]
accent = 0, 50, 220
background = 255, 255, 255
border = 255, 255, 255, 23
text = 0, 0, 0
text_muted = 0, 0, 0, 200
[Scopes]
background = 255, 255, 255
guides = 0, 0, 0, 170
[Spectrum]
background = 255, 255, 255
line = 0, 50, 220
guides = 0, 0, 0, 40
labels = 0, 0, 0, 40
[Oscilloscope]
line = 0, 50, 220
guides = 0, 0, 0, 40
[Vectorscope]
band_low = 0, 50, 180
band_mid = 11, 180, 140
band_high = 200, 50, 180
guides = 0, 0, 0, 70
[Spectrogram]
background = 255, 255, 255, 0
mono = 255, 105, 180
heat_low = 15, 30, 240
heat_mid = 15, 30, 240
heat_high = 255, 105, 180
[VUMeter]
peak = 240, 30, 180
scale = 0, 0, 0, 100
labels = 0, 0, 0, 120
[LUFSMeter]
level = 0, 50, 220
track = 0, 50, 220, 20
target = 0, 50, 220, 120
scale = 0, 0, 0, 200
labels = 0, 0, 0, 225
[Waveform]
line = 0, 0, 0, 120
band_low = 0, 50, 180
band_mid = 70, 160, 240
band_high = 255, 105, 180
guides = 0, 0, 0, 120
)iro"},
{"Chroma Blue", R"iro(
[Theme]
format = prism-theme
version = 2
credit = Prism
description = Chroma key for transparent overlays
[App]
accent = 56, 140, 255
background = 8, 8, 14
border = 180, 200, 240, 23
text = 255, 255, 255
text_muted = 160, 180, 220
warning = 255, 210, 0
danger = 255, 75, 75
[Scopes]
background = 0, 0, 255
guides = 0, 0, 255
[Spectrum]
background = 0, 0, 255
line = 255, 255, 255
guides = 0, 0, 255
labels = 0, 0, 255
[Oscilloscope]
background = 0, 0, 255
line = 255, 255, 255
guides = 0, 0, 255
[Vectorscope]
background = 0, 0, 255
trace = 255, 255, 255
band_low = 255, 255, 255, 120
band_mid = 255, 255, 255, 120
band_high = 255, 255, 255, 120
guides = 0, 0, 255
[Spectrogram]
background = 0, 0, 255
mono = 255, 255, 255
heat_low = 0, 0, 255, 255
heat_high = 255, 255, 255, 255
[VUMeter]
background = 0, 0, 255
level = 255, 255, 255
track = 255, 255, 255, 255
peak = 255, 220, 0
clip = 255, 75, 75, 255
scale = 0, 0, 255
labels = 0, 0, 255
[LUFSMeter]
background = 0, 0, 255
level = 255, 255, 255
track = 255, 255, 255, 255
target = 255, 255, 255, 255
scale = 0, 0, 255
labels = 0, 0, 255
[Waveform]
background = 0, 0, 255
line = 255, 255, 255
band_low = 255, 255, 255, 120
band_mid = 255, 255, 255, 120
band_high = 255, 255, 255, 120
guides = 0, 0, 255
)iro"},
{"Chroma Green", R"iro(
[Theme]
format = prism-theme
version = 2
credit = Prism
description = Chroma key for transparent overlays
[App]
accent = 0, 230, 80
background = 8, 12, 8
border = 200, 240, 200, 23
text = 255, 255, 255
text_muted = 160, 210, 160
warning = 255, 210, 0
danger = 255, 75, 75
[Scopes]
background = 0, 255, 0
guides = 0, 255, 0
[Spectrum]
background = 0, 255, 0
line = 255, 255, 255
guides = 0, 255, 0
labels = 0, 255, 0
[Oscilloscope]
background = 0, 255, 0
line = 255, 255, 255
guides = 0, 255, 0
[Vectorscope]
background = 0, 255, 0
trace = 255, 255, 255
band_low = 255, 255, 255, 120
band_mid = 255, 255, 255, 120
band_high = 255, 255, 255, 120
guides = 0, 255, 0
[Spectrogram]
background = 0, 255, 0
mono = 255, 255, 255
heat_low = 0, 255, 0, 255
heat_high = 255, 255, 255, 255
[VUMeter]
background = 0, 255, 0
level = 255, 255, 255
track = 255, 255, 255, 28
peak = 255, 220, 0
clip = 255, 75, 75, 230
scale = 0, 255, 0
labels = 0, 255, 0
[LUFSMeter]
background = 0, 255, 0
level = 255, 255, 255
track = 255, 255, 255, 28
target = 255, 255, 255, 75
scale = 0, 255, 0
labels = 0, 255, 0
[Waveform]
background = 0, 255, 0
line = 255, 255, 255
band_low = 255, 255, 255, 120
band_mid = 255, 255, 255, 120
band_high = 255, 255, 255, 120
guides = 0, 255, 0
)iro"},
{"Redshift", R"iro(
[Theme]
format = prism-theme
version = 2
credit = Boof2015
description = A very red theme
[App]
accent = 230, 0, 69
background = 15, 15, 15
text = 255, 255, 255
text_muted = 172, 192, 222
warning = 230, 0, 69
danger = 230, 0, 69
[Scopes]
background = 15, 15, 15
guides = 59, 64, 71
[Spectrum]
line = 255, 255, 255
guides = 56, 58, 61
labels = 56, 58, 61
[Oscilloscope]
background = 15, 15, 15
line = 230, 0, 69
guides = 56, 58, 61
[Vectorscope]
background = 15, 15, 15
trace = 230, 0, 69
band_low = 230, 0, 69
band_mid = 102, 90, 255
band_high = 0, 255, 255
guides = 56, 58, 61
[Spectrogram]
mono = 230, 0, 69
heat_low = 180, 20, 40, 200
heat_mid = 220, 0, 55, 250
heat_high = 255, 200, 200
[VUMeter]
background = 15, 15, 15
level = 153, 0, 53
track = 153, 0, 53, 20
peak = 230, 0, 69
clip = 255, 0, 0, 230
scale = 86, 96, 111
labels = 86, 96, 111
[LUFSMeter]
background = 15, 15, 15
level = 230, 0, 69
track = 230, 0, 69, 20
target = 230, 0, 69, 64
scale = 86, 96, 111
labels = 86, 96, 111
[Waveform]
background = 15, 15, 15
line = 230, 0, 69
band_low = 230, 0, 69
band_mid = 102, 90, 255
band_high = 0, 255, 255
guides = 86, 96, 111
)iro"},
{"Stanky Leg", R"iro(
[Theme]
format = prism-theme
version = 2
credit = MrAlibi
description = I tripped, and now my leg turned too stanky
[App]
accent = 69, 20, 184
background = 0, 0, 0
border = 255, 255, 255, 23
text = 255, 255, 255
[Scopes]
guides = 255, 255, 255, 26
[Vectorscope]
band_low = 177, 105, 219
band_mid = 108, 31, 196
band_high = 69, 20, 184
[Spectrogram]
mono = 86, 25, 230
heat_low = 86, 25, 230
heat_mid = 177, 105, 219
heat_high = 177, 105, 219
[Waveform]
line = 86, 25, 230
band_low = 177, 105, 219
band_mid = 108, 31, 196
band_high = 69, 20, 184
)iro"},
}};
std::vector<TuiTheme> themes;
themes.reserve(sources.size() + 1);
themes.push_back(defaultTuiTheme());
for (const auto& source : sources) {
TuiTheme parsed;
std::string ignored;
if (parseIroThemeText(source.content, source.name, parsed, &ignored)) {
themes.push_back(std::move(parsed));
}
}
return themes;
}
} // namespace
bool parseIroThemeText(const std::string& content,
const std::string& fallbackName,
TuiTheme& theme,
std::string* error) {
TokenMap tokens;
std::string section;
std::string credit;
std::string description;
bool formatSeen = false;
bool versionSeen = false;
std::istringstream input(content);
std::string rawLine;
size_t lineNumber = 0;
while (std::getline(input, rawLine)) {
++lineNumber;
const std::string line = trim(rawLine);
if (line.empty() || line.front() == '#' || line.front() == ';') continue;
if (line.front() == '[' && line.back() == ']') {
section = normalizeKey(line.substr(1, line.size() - 2));
continue;
}
const size_t separator = line.find('=');
if (separator == std::string::npos || section.empty()) continue;
const std::string key = normalizeKey(line.substr(0, separator));
const std::string value = trim(line.substr(separator + 1));
if (section == "theme") {
if (key == "format") {
formatSeen = true;
if (value != "prism-theme") {
if (error) *error = "unsupported theme format at line " +
std::to_string(lineNumber);
return false;
}
} else if (key == "version") {
versionSeen = true;
if (value != "2") {
if (error) *error = "unsupported theme version at line " +
std::to_string(lineNumber);
return false;
}
} else if (key == "credit") {
credit = value;
} else if (key == "description") {
description = value;
}
continue;
}
if (const auto parsed = parseColor(value)) {
tokens[section + "." + key] = *parsed;
}
}
(void)formatSeen;
(void)versionSeen;
const RgbaColor appBackground = colorOr(
tokens, "app", "background", rgba(0, 0, 0));
const ThemeColor background = flatten(appBackground, {0, 0, 0});
const RgbaColor accentRaw = colorOr(
tokens, "app", "accent", rgba(56, 189, 248));
const RgbaColor textRaw = colorOr(
tokens, "app", "text", rgba(255, 255, 255));
const RgbaColor mutedRaw = colorOr(
tokens, "app", "text_muted", withAlpha(textRaw, 0.42f));
const RgbaColor borderRaw = colorOr(
tokens, "app", "border", rgba(255, 255, 255, 0.09f));
const RgbaColor guidesRaw = colorOr(
tokens, "scopes", "guides", rgba(255, 255, 255, 0.10f));
const RgbaColor scopeBackgroundRaw = colorOr(
tokens, "scopes", "background", appBackground);
const ThemeColor scopeBackground = flatten(scopeBackgroundRaw, background);
theme = {};
theme.id = fallbackName.empty() ? "Default" : fallbackName;
theme.name = theme.id;
theme.credit = credit;
theme.description = description;
theme.background = background;
theme.accent = flatten(accentRaw, background);
theme.text = flatten(textRaw, background);
theme.muted = flatten(mutedRaw, background);
theme.border = flatten(borderRaw, background);
theme.selection = flatten(withAlpha(accentRaw, 0.18f), background);
theme.warning = flatten(colorOr(
tokens, "app", "warning", rgba(255, 191, 0)), background);
theme.danger = flatten(colorOr(
tokens, "app", "danger", rgba(248, 113, 113)), background);
theme.scopeGuides = flatten(guidesRaw, scopeBackground);
theme.scopeGuidesSecondary = flatten(multiplyAlpha(guidesRaw, 0.5f), scopeBackground);
const auto resolveBackground = [&](const std::string& sectionName) {
return flatten(colorOr(
tokens, sectionName, "background", scopeBackgroundRaw), background);
};
theme.spectrumBackground = resolveBackground("spectrum");
const RgbaColor spectrumLineRaw = colorOr(
tokens, "spectrum", "line", accentRaw);
theme.spectrumLine = flatten(spectrumLineRaw, theme.spectrumBackground);
theme.spectrumLabels = flatten(colorOr(
tokens, "spectrum", "labels", colorOr(
tokens, "spectrum", "guides", guidesRaw)), theme.spectrumBackground);
theme.oscilloscopeBackground = resolveBackground("oscilloscope");
theme.oscilloscopeLine = flatten(colorOr(
tokens, "oscilloscope", "line", accentRaw), theme.oscilloscopeBackground);
theme.oscilloscopeGuides = flatten(colorOr(
tokens, "oscilloscope", "guides", guidesRaw), theme.oscilloscopeBackground);
theme.vectorscopeBackground = resolveBackground("vectorscope");
theme.vectorscopeTrace = flatten(colorOr(
tokens, "vectorscope", "trace", accentRaw), theme.vectorscopeBackground);
const RgbaColor vectorGuidesRaw = colorOr(
tokens, "vectorscope", "guides", guidesRaw);
theme.vectorscopeGuides = flatten(vectorGuidesRaw, theme.vectorscopeBackground);
theme.vectorscopeGuidesSecondary = flatten(
multiplyAlpha(vectorGuidesRaw, 0.5f), theme.vectorscopeBackground);
theme.vectorscopeBands = {{
flatten(colorOr(tokens, "vectorscope", "band_low", rgba(255, 68, 68)), theme.vectorscopeBackground),
flatten(colorOr(tokens, "vectorscope", "band_mid", rgba(68, 221, 68)), theme.vectorscopeBackground),
flatten(colorOr(tokens, "vectorscope", "band_high", rgba(68, 136, 255)), theme.vectorscopeBackground),
}};
theme.spectrogramBackground = resolveBackground("spectrogram");
theme.spectrogramMono = flatten(colorOr(
tokens, "spectrogram", "mono", accentRaw), theme.spectrogramBackground);
theme.spectrogramHeat = {{
flatten(colorOr(tokens, "spectrogram", "heat_low", rgba(15, 7, 33)), theme.spectrogramBackground),
flatten(colorOr(tokens, "spectrogram", "heat_mid", rgba(163, 26, 121)), theme.spectrogramBackground),
flatten(colorOr(tokens, "spectrogram", "heat_high", rgba(255, 241, 209)), theme.spectrogramBackground),
}};
theme.vuBackground = resolveBackground("vumeter");
const RgbaColor vuLevelRaw = colorOr(tokens, "vumeter", "level", accentRaw);
const RgbaColor vuPeakRaw = colorOr(tokens, "vumeter", "peak", rgba(255, 127, 0));
theme.vuLevel = flatten(vuLevelRaw, theme.vuBackground);
theme.vuTrack = flatten(colorOr(
tokens, "vumeter", "track", withAlpha(vuLevelRaw, 0.08f)), theme.vuBackground);
theme.vuPeak = flatten(vuPeakRaw, theme.vuBackground);
theme.vuClip = flatten(colorOr(
tokens, "vumeter", "clip", rgba(255, 120, 80, 0.9f)), theme.vuBackground);
theme.vuScale = flatten(colorOr(
tokens, "vumeter", "scale", guidesRaw), theme.vuBackground);
theme.vuLabels = flatten(colorOr(
tokens, "vumeter", "labels", mutedRaw), theme.vuBackground);
theme.vuNeedleLeft = flatten(colorOr(
tokens, "vumeter", "needle_left", vuLevelRaw), theme.vuBackground);
theme.vuNeedleRight = flatten(colorOr(
tokens, "vumeter", "needle_right", vuPeakRaw), theme.vuBackground);
theme.vuNeedleCombined = flatten(colorOr(
tokens, "vumeter", "needle_combined", vuLevelRaw), theme.vuBackground);
theme.lufsBackground = resolveBackground("lufsmeter");
const RgbaColor lufsLevelRaw = colorOr(tokens, "lufsmeter", "level", accentRaw);
theme.lufsLevel = flatten(lufsLevelRaw, theme.lufsBackground);
theme.lufsTrack = flatten(colorOr(
tokens, "lufsmeter", "track", withAlpha(lufsLevelRaw, 0.08f)), theme.lufsBackground);
theme.lufsTarget = flatten(colorOr(
tokens, "lufsmeter", "target", withAlpha(lufsLevelRaw, 0.25f)), theme.lufsBackground);
theme.lufsScale = flatten(colorOr(
tokens, "lufsmeter", "scale", guidesRaw), theme.lufsBackground);
theme.lufsLabels = flatten(colorOr(
tokens, "lufsmeter", "labels", mutedRaw), theme.lufsBackground);
theme.waveformBackground = resolveBackground("waveform");
theme.waveformLine = flatten(colorOr(
tokens, "waveform", "line", accentRaw), theme.waveformBackground);
const RgbaColor waveformGuidesRaw = colorOr(
tokens, "waveform", "guides", guidesRaw);
theme.waveformGuides = flatten(waveformGuidesRaw, theme.waveformBackground);
theme.waveformGuidesSecondary = flatten(
multiplyAlpha(waveformGuidesRaw, 0.5f), theme.waveformBackground);
theme.waveformBands = {{
flatten(colorOr(tokens, "waveform", "band_low", rgba(255, 68, 68)), theme.waveformBackground),
flatten(colorOr(tokens, "waveform", "band_mid", rgba(68, 221, 68)), theme.waveformBackground),
flatten(colorOr(tokens, "waveform", "band_high", rgba(68, 136, 255)), theme.waveformBackground),
}};
return true;
}
IroThemeLibrary::IroThemeLibrary(std::filesystem::path directory)
: directory_(std::move(directory)) {}
bool IroThemeLibrary::load(std::string* warning) {
themes_ = bundledTuiThemes();
std::error_code filesystemError;
if (!std::filesystem::exists(directory_, filesystemError)) return true;
if (filesystemError || !std::filesystem::is_directory(directory_, filesystemError)) {
if (warning) *warning = "Could not read Prism Themes at " + directory_.string();
return false;
}
std::vector<std::filesystem::path> paths;
for (const auto& entry : std::filesystem::directory_iterator(directory_, filesystemError)) {
if (filesystemError) break;
const std::string filename = entry.path().filename().string();
if (entry.is_regular_file() && !filename.empty() && filename.front() != '_' &&
hasExtensionIro(entry.path())) {
paths.push_back(entry.path());
}
}
std::sort(paths.begin(), paths.end());
std::vector<std::string> skipped;
for (const auto& path : paths) {
std::ifstream input(path);
if (!input) {
skipped.push_back(path.filename().string());
continue;
}
const std::string content{
std::istreambuf_iterator<char>(input),
std::istreambuf_iterator<char>()};
TuiTheme parsed;
std::string parseError;
if (!parseIroThemeText(content, path.stem().string(), parsed, &parseError)) {
skipped.push_back(path.filename().string());
continue;
}
const auto existing = std::find_if(
themes_.begin(), themes_.end(), [&](const TuiTheme& candidate) {
return candidate.id == parsed.id;
});
if (existing == themes_.end()) {
themes_.push_back(std::move(parsed));
} else {
*existing = std::move(parsed);
}
}
std::stable_sort(themes_.begin(), themes_.end(), [](const auto& left, const auto& right) {
if (left.id == "Default") return right.id != "Default";
if (right.id == "Default") return false;
return left.name < right.name;
});
if (warning && !skipped.empty()) {
*warning = "Skipped " + std::to_string(skipped.size()) + " invalid .iro theme" +
(skipped.size() == 1 ? "" : "s");
}
return true;
}
const TuiTheme* IroThemeLibrary::find(const std::string& id) const {
const auto found = std::find_if(
themes_.begin(), themes_.end(), [&](const TuiTheme& theme) {
return theme.id == id;
});
return found == themes_.end() ? nullptr : &*found;
}
const TuiTheme& IroThemeLibrary::resolve(const std::string& id) const {
if (const auto* theme = find(id)) return *theme;
if (!themes_.empty()) return themes_.front();
static const TuiTheme fallback = defaultTuiTheme();
return fallback;
}
std::string IroThemeLibrary::adjacentId(const std::string& id, int direction) const {
if (themes_.empty()) return "Default";
const auto found = std::find_if(
themes_.begin(), themes_.end(), [&](const TuiTheme& theme) {
return theme.id == id;
});
const int current = found == themes_.end()
? 0
: static_cast<int>(std::distance(themes_.begin(), found));
const int count = static_cast<int>(themes_.size());
return themes_[static_cast<size_t>(
(current + (direction < 0 ? -1 : 1) + count) % count)].id;
}
std::filesystem::path defaultIroThemeDirectory() {
#if defined(_WIN32)
PWSTR documents = nullptr;
if (SUCCEEDED(SHGetKnownFolderPath(
FOLDERID_Documents, KF_FLAG_DEFAULT, nullptr, &documents)) &&
documents != nullptr) {
const std::filesystem::path path(documents);
CoTaskMemFree(documents);
return path / "Prism Themes";
}
const std::string userProfile = environmentValue("USERPROFILE");
if (!userProfile.empty()) {
return std::filesystem::path(userProfile) / "Documents" / "Prism Themes";
}
#else
const std::string home = environmentValue("HOME");
if (!home.empty()) {
return std::filesystem::path(home) / "Documents" / "Prism Themes";
}
#endif
return std::filesystem::path("Prism Themes");
}
} // namespace Prism::Tui
+106
View File
@@ -0,0 +1,106 @@
#pragma once
#include <array>
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
namespace Prism::Tui {
struct ThemeColor {
uint8_t red = 0;
uint8_t green = 0;
uint8_t blue = 0;
bool operator==(const ThemeColor& other) const {
return red == other.red && green == other.green && blue == other.blue;
}
};
struct TuiTheme {
std::string id = "Default";
std::string name = "Default";
std::string credit;
std::string description;
ThemeColor background;
ThemeColor accent;
ThemeColor text;
ThemeColor muted;
ThemeColor border;
ThemeColor selection;
ThemeColor warning;
ThemeColor danger;
ThemeColor scopeGuides;
ThemeColor scopeGuidesSecondary;
ThemeColor spectrumBackground;
ThemeColor spectrumLine;
ThemeColor spectrumLabels;
ThemeColor oscilloscopeBackground;
ThemeColor oscilloscopeLine;
ThemeColor oscilloscopeGuides;
ThemeColor vectorscopeBackground;
ThemeColor vectorscopeTrace;
ThemeColor vectorscopeGuides;
ThemeColor vectorscopeGuidesSecondary;
std::array<ThemeColor, 3> vectorscopeBands{};
ThemeColor spectrogramBackground;
ThemeColor spectrogramMono;
std::array<ThemeColor, 3> spectrogramHeat{};
ThemeColor vuBackground;
ThemeColor vuLevel;
ThemeColor vuTrack;
ThemeColor vuPeak;
ThemeColor vuClip;
ThemeColor vuScale;
ThemeColor vuLabels;
ThemeColor vuNeedleLeft;
ThemeColor vuNeedleRight;
ThemeColor vuNeedleCombined;
ThemeColor lufsBackground;
ThemeColor lufsLevel;
ThemeColor lufsTrack;
ThemeColor lufsTarget;
ThemeColor lufsScale;
ThemeColor lufsLabels;
ThemeColor waveformBackground;
ThemeColor waveformLine;
ThemeColor waveformGuides;
ThemeColor waveformGuidesSecondary;
std::array<ThemeColor, 3> waveformBands{};
};
TuiTheme defaultTuiTheme();
bool parseIroThemeText(const std::string& content,
const std::string& fallbackName,
TuiTheme& theme,
std::string* error = nullptr);
class IroThemeLibrary {
public:
explicit IroThemeLibrary(std::filesystem::path directory);
bool load(std::string* warning = nullptr);
const std::vector<TuiTheme>& themes() const { return themes_; }
const TuiTheme* find(const std::string& id) const;
const TuiTheme& resolve(const std::string& id) const;
std::string adjacentId(const std::string& id, int direction) const;
const std::filesystem::path& directory() const { return directory_; }
private:
std::filesystem::path directory_;
std::vector<TuiTheme> themes_;
};
std::filesystem::path defaultIroThemeDirectory();
} // namespace Prism::Tui
+106 -2
View File
@@ -10,6 +10,7 @@
#include "spectrum_peak_model.h"
#include "system_audio_capture.h"
#include "tui_settings.h"
#include "tui_theme.h"
#include <algorithm>
#include <cmath>
@@ -360,6 +361,104 @@ void testSpectrumPeakModel() {
"silent spectra should not produce a peak readout");
}
void testIroThemes() {
constexpr const char* content = R"iro(
[Theme]
format = prism-theme
version = 2
credit = Prism Test
description = Native theme parser fixture
[App]
background = 10, 20, 30
accent = 40, 50, 60
text = 230, 240, 250
text_muted = 100, 110, 120
border = 70, 80, 90
[Scopes]
background = 11, 21, 31
guides = 71, 81, 91
[Spectrum]
line = 1, 2, 3
[Vectorscope]
band_low = 4, 5, 6
band_mid = 7, 8, 9
band_high = 10, 11, 12
[Spectrogram]
heat_low = 13, 14, 15
heat_mid = 16, 17, 18
heat_high = 19, 20, 21
[Waveform]
line = 22, 23, 24
)iro";
Prism::Tui::TuiTheme parsed;
std::string error;
require(Prism::Tui::parseIroThemeText(content, "Test Theme", parsed, &error),
"valid Prism v2 .iro themes should parse natively");
require(parsed.id == "Test Theme" && parsed.credit == "Prism Test" &&
parsed.background == Prism::Tui::ThemeColor{10, 20, 30} &&
parsed.spectrumLine == Prism::Tui::ThemeColor{1, 2, 3},
"theme metadata and primary scope colors should be retained");
require(parsed.oscilloscopeLine == Prism::Tui::ThemeColor{40, 50, 60} &&
parsed.oscilloscopeBackground == Prism::Tui::ThemeColor{11, 21, 31},
"missing scope colors should inherit the shared Prism theme defaults");
require(parsed.vectorscopeBands[2] == Prism::Tui::ThemeColor{10, 11, 12} &&
parsed.spectrogramHeat[1] == Prism::Tui::ThemeColor{16, 17, 18} &&
parsed.waveformLine == Prism::Tui::ThemeColor{22, 23, 24},
"scope-specific .iro palettes should map to their TUI renderers");
Prism::Tui::TuiTheme invalid;
require(!Prism::Tui::parseIroThemeText(
"[Theme]\nformat=prism-theme\nversion=99\n",
"Invalid", invalid, &error),
"unsupported .iro versions should be rejected");
const auto root = std::filesystem::temp_directory_path() /
"prism-tui-iro-theme-test";
std::error_code ignored;
std::filesystem::remove_all(root, ignored);
std::filesystem::create_directories(root, ignored);
{
std::ofstream output(root / "Test Theme.iro");
output << content;
}
{
std::ofstream output(root / "Redshift.iro");
output << content;
}
{
std::ofstream output(root / "_TEMPLATE.iro");
output << content;
}
{
std::ofstream output(root / "Broken.iro");
output << "[Theme]\nformat=not-prism\nversion=2\n";
}
Prism::Tui::IroThemeLibrary library(root);
std::string warning;
require(library.load(&warning) && library.themes().size() == 7 &&
library.find("Default") && library.find("Alpha Centauri") &&
library.find("Chroma Blue") && library.find("Chroma Green") &&
library.find("Redshift") && library.find("Stanky Leg") &&
library.find("Test Theme"),
"theme discovery should include bundled themes, load .iro files, and ignore templates");
require(library.find("Redshift")->spectrumLine ==
Prism::Tui::ThemeColor{1, 2, 3},
"managed .iro files should override bundled themes with the same filename stem");
const std::string nextTheme = library.adjacentId("Default", 1);
require(!warning.empty() && nextTheme != "Default" &&
library.find(nextTheme) && library.adjacentId(nextTheme, -1) == "Default",
"theme discovery should report invalid files and cycle deterministically");
std::filesystem::remove_all(root, ignored);
}
void testSettingsModelAndPersistence() {
Prism::Tui::TuiSettings settings;
settings.inputTrimDb = 30.0f;
@@ -373,7 +472,8 @@ void testSettingsModelAndPersistence() {
"settings normalization should enforce public ranges");
const auto pages = Prism::Tui::settingsPages();
require(pages.size() == 8 &&
require(pages.size() == 9 &&
Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::Appearance).size() == 1 &&
Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 2,
"settings should expose shallow category pages");
Prism::Tui::TuiSettings adjusted;
@@ -432,6 +532,7 @@ void testSettingsModelAndPersistence() {
"settings persistence should include an edited rack layout");
adjusted.vectorscopeMode = Prism::Tui::VectorscopeMode::PolarBipolar;
adjusted.vectorscopeDetail = Prism::Tui::VectorscopeDetail::Maximum;
adjusted.themeId = "Test Theme";
std::string error;
require(Prism::Tui::saveSettings(adjusted, settingsPath, &error),
"settings should persist to a TUI-specific configuration file");
@@ -458,6 +559,7 @@ void testProfileLibrary() {
"the profile library should always provide a default profile");
Prism::Tui::TuiSettings settings;
settings.themeId = "Test Theme";
settings.inputTrimDb = 4.0f;
settings.refreshRate = 30;
settings.spectrogramContrast = 1.7f;
@@ -483,8 +585,9 @@ void testProfileLibrary() {
if (text.find("id=" + profileId) == std::string::npos) continue;
foundProfileFile = true;
require(text.find("format=prism-tui-profile") != std::string::npos &&
text.find("theme_id=Test Theme") != std::string::npos &&
text.find("refresh_rate=") == std::string::npos,
".prsmt files should be versioned and exclude global refresh settings");
".prsmt files should retain themes while excluding global refresh settings");
}
require(foundProfileFile,
"saved TUI profiles should use the .prsmt extension");
@@ -846,6 +949,7 @@ int main() {
testProjectionAndLayout();
testMeterDisplayModels();
testSpectrumPeakModel();
testIroThemes();
testSettingsModelAndPersistence();
testProfileLibrary();
testScopePlotModels();