From 1bb096c231773b8811611d16eb749e5fc80018e9 Mon Sep 17 00:00:00 2001 From: Boof2015 <75185879+Boof2015@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:45:32 -0400 Subject: [PATCH] tui support themes --- tui/CMakeLists.txt | 4 + tui/src/tui_runtime.cpp | 395 +++++++++++------- tui/src/tui_settings.cpp | 27 +- tui/src/tui_settings.h | 3 + tui/src/tui_theme.cpp | 873 +++++++++++++++++++++++++++++++++++++++ tui/src/tui_theme.h | 106 +++++ tui/test/tui_tests.cpp | 108 ++++- 7 files changed, 1373 insertions(+), 143 deletions(-) create mode 100644 tui/src/tui_theme.cpp create mode 100644 tui/src/tui_theme.h diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index 7ced950..0159493 100644 --- a/tui/CMakeLists.txt +++ b/tui/CMakeLists.txt @@ -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}) diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index c150497..d14044d 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -8,6 +8,7 @@ #include "scope_plot_model.h" #include "snapshot_store.h" #include "tui_settings.h" +#include "tui_theme.h" #include #include @@ -147,6 +148,7 @@ struct InterfaceState { PanelId focusedPanel = PanelId::Spectrum; std::optional 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 settingsSelections{}; + std::array 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(std::lround(std::clamp( + static_cast(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, 3> baseColors = {{ - {{255, 68, 68}}, - {{68, 221, 68}}, - {{68, 136, 255}}, - }}; std::array, 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(bucket + 1) / static_cast(ageBuckets); - colors[band][bucket] = Color::RGB( - static_cast(std::lround( - static_cast(baseColors[band][0]) * brightness)), - static_cast(std::lround( - static_cast(baseColors[band][1]) * brightness)), - static_cast(std::lround( - static_cast(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 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& from, - const std::array& 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(std::lround(from[0] + (to[0] - from[0]) * t)), - static_cast(std::lround(from[1] + (to[1] - from[1]) * t)), - static_cast(std::lround(from[2] + (to[2] - from[2]) * t))); + static_cast(std::lround(from.red + + (static_cast(to.red) - from.red) * t)), + static_cast(std::lround(from.green + + (static_cast(to.green) - from.green) * t)), + static_cast(std::lround(from.blue + + (static_cast(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 stops = {0.0f, 0.16f, 0.36f, 0.58f, 0.78f, 1.0f}; - constexpr std::array, 6> colors = {{ - {{5, 3, 12}}, - {{15, 7, 33}}, - {{61, 11, 94}}, - {{163, 26, 121}}, - {{255, 82, 87}}, - {{255, 241, 209}}, + constexpr std::array stops = {0.0f, 0.20f, 0.62f, 1.0f}; + const std::array 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 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, 3> colors = {{ - {{255, 68, 68}}, - {{68, 221, 68}}, - {{68, 136, 255}}, - }}; std::array 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(std::lround( - weights[band] * static_cast(colors[band][channel]))); + weights[band] * static_cast(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(canvasHeight) / static_cast(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 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 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 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; diff --git a/tui/src/tui_settings.cpp b/tui/src/tui_settings.cpp index 368f952..39e7cc8 100644 --- a/tui/src/tui_settings.cpp +++ b/tui/src/tui_settings.cpp @@ -13,6 +13,10 @@ namespace Prism::Tui { namespace { +const std::vector kAppearanceSettings = { + {SettingId::Theme, "Theme", "Uses Prism .iro files from the shared Prism Themes folder."}, +}; + const std::vector 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 settingsPages() { return { + SettingsPage::Appearance, SettingsPage::General, SettingsPage::Spectrum, SettingsPage::Oscilloscope, @@ -365,6 +376,7 @@ std::vector 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& 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& 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'; } diff --git a/tui/src/tui_settings.h b/tui/src/tui_settings.h index 5badb61..87cc34a 100644 --- a/tui/src/tui_settings.h +++ b/tui/src/tui_settings.h @@ -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(); diff --git a/tui/src/tui_theme.cpp b/tui/src/tui_theme.cpp new file mode 100644 index 0000000..24fe2d5 --- /dev/null +++ b/tui/src/tui_theme.cpp @@ -0,0 +1,873 @@ +#include "tui_theme.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#include +#include +#include +#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 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(std::tolower(character))); + separator = false; + } else { + separator = true; + } + } + return result; +} + +float clampByte(float value) { + return std::clamp(value, 0.0f, 255.0f); +} + +std::optional 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 split(const std::string& value, char separator) { + std::vector parts; + std::istringstream input(value); + std::string part; + while (std::getline(input, part, separator)) parts.push_back(trim(part)); + return parts; +} + +std::optional 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(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 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 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 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(std::lround(std::clamp( + value * alpha + static_cast(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(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 bundledTuiThemes() { + static constexpr std::array 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 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 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 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(input), + std::istreambuf_iterator()}; + 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(std::distance(themes_.begin(), found)); + const int count = static_cast(themes_.size()); + return themes_[static_cast( + (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 diff --git a/tui/src/tui_theme.h b/tui/src/tui_theme.h new file mode 100644 index 0000000..2fe18ca --- /dev/null +++ b/tui/src/tui_theme.h @@ -0,0 +1,106 @@ +#pragma once + +#include +#include +#include +#include +#include + +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 vectorscopeBands{}; + + ThemeColor spectrogramBackground; + ThemeColor spectrogramMono; + std::array 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 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& 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 themes_; +}; + +std::filesystem::path defaultIroThemeDirectory(); + +} // namespace Prism::Tui diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index 7d692ce..39180fd 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -10,6 +10,7 @@ #include "spectrum_peak_model.h" #include "system_audio_capture.h" #include "tui_settings.h" +#include "tui_theme.h" #include #include @@ -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();