profile and customization system for tui

This commit is contained in:
Boof2015
2026-08-15 18:04:27 -04:00
parent 81e97c7b3e
commit 700c8df4af
10 changed files with 2371 additions and 256 deletions
+1
View File
@@ -34,6 +34,7 @@ add_library(prism_tui_analysis STATIC
src/dashboard_layout.cpp
src/display_model.cpp
src/meter_display_model.cpp
src/profile_library.cpp
src/scrolling_history.cpp
src/scope_plot_model.cpp
src/spectrum_peak_model.cpp
+10 -3
View File
@@ -60,11 +60,18 @@ std::string usageText() {
"Controls:\n"
" Tab / Shift-Tab Focus the next or previous panel.\n"
" Enter Expand the focused panel or restore the dashboard.\n"
" p Open profiles to load, save, or overwrite setups.\n"
" s Open settings.\n"
" l Cycle automatic, stacked, and column layouts.\n"
" v Cycle vectorscope display modes.\n"
" l Edit the scope rack layout.\n"
" Arrow keys Navigate scopes spatially while editing.\n"
" Shift + arrows Reorder scopes while editing (Ctrl arrows also work).\n"
" [ / ] Resize a scope while editing the layout.\n"
" , / . Resize a row while editing the layout.\n"
" n / x Split a row or remove a scope while editing.\n"
" a Add a removed scope by name while editing.\n"
" ? Show layout editing help and fallback keys.\n"
" 1 / 2 / 3 / 4 / 5 Focus Spectrum, Oscilloscope, Vectorscope, VU, or LUFS.\n"
" 6 / 7 Focus Spectrogram or Waveform.\n"
" 6 / 7 Focus Spectrogram or Waveform (also layout shortcuts).\n"
" r Reset analyzers and integrated loudness.\n"
" q / Esc / Ctrl-C Quit.\n";
}
+405 -82
View File
@@ -1,7 +1,9 @@
#include "dashboard_layout.h"
#include <algorithm>
#include <cstdlib>
#include <numeric>
#include <sstream>
#include <utility>
namespace Prism::Tui {
@@ -23,7 +25,7 @@ MinimumSize panelMinimumSize(PanelId panel) {
case PanelId::VUMeter:
return {30, 5};
case PanelId::LUFSMeter:
return {30, 7};
return {30, 4};
case PanelId::Spectrogram:
return {30, 8};
case PanelId::Waveform:
@@ -110,7 +112,7 @@ void resolveNode(const LayoutNode& node,
int height,
std::vector<PanelRect>& output) {
if (node.isLeaf()) {
output.push_back({*node.panel, x, y, width, height});
output.push_back({*node.panel, 0, 0, x, y, width, height});
return;
}
if (node.children.empty()) {
@@ -142,63 +144,56 @@ void resolveNode(const LayoutNode& node,
}
}
LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) {
constexpr int minimumColumnsWidth = 72;
constexpr int minimumColumnsHeight = 18;
if (requested == LayoutPreset::Columns &&
(width < minimumColumnsWidth || height < minimumColumnsHeight)) {
return LayoutPreset::Stacked;
int rackRowMinimumHeight(const RackRow& row) {
int result = 1;
for (const auto& tile : row.tiles) {
result = std::max(result, panelMinimumSize(tile.panel).height);
}
if (requested != LayoutPreset::Automatic) {
return requested;
}
return width >= minimumColumnsWidth && height >= minimumColumnsHeight
? LayoutPreset::Columns
: LayoutPreset::Stacked;
return result;
}
LayoutNode makeRoot(LayoutPreset preset, int width, int height) {
if (preset == LayoutPreset::Columns) {
if (width >= 108 && height >= 34) {
return LayoutNode::split(SplitAxis::Columns, {
LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Spectrum, 3),
LayoutNode::leaf(PanelId::Oscilloscope, 2),
LayoutNode::leaf(PanelId::Waveform, 2),
}, 3),
LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Vectorscope, 2),
LayoutNode::leaf(PanelId::VUMeter, 1),
LayoutNode::leaf(PanelId::LUFSMeter, 1),
LayoutNode::leaf(PanelId::Spectrogram, 2),
}, 1),
});
std::vector<RackTile> visibleRackTiles(const RackRow& row, int width) {
std::vector<RackTile> result;
int requiredWidth = 0;
for (const auto& tile : row.tiles) {
const int tileMinimum = panelMinimumSize(tile.panel).width;
if (!result.empty() && requiredWidth + tileMinimum > width) {
break;
}
return LayoutNode::split(SplitAxis::Columns, {
LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Spectrum, 3),
LayoutNode::leaf(PanelId::Oscilloscope, 2),
}, 3),
LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Vectorscope, 2),
LayoutNode::leaf(PanelId::VUMeter, 1),
LayoutNode::leaf(PanelId::LUFSMeter, 1),
}, 1),
});
result.push_back(tile);
requiredWidth += tileMinimum;
}
if (width >= 60 && height >= 14) {
return LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Spectrum, 3),
LayoutNode::split(SplitAxis::Columns, {
LayoutNode::leaf(PanelId::VUMeter),
LayoutNode::leaf(PanelId::LUFSMeter),
}, 2),
});
return result;
}
const char* panelConfigName(PanelId panel) {
switch (panel) {
case PanelId::Spectrum: return "spectrum";
case PanelId::Oscilloscope: return "oscilloscope";
case PanelId::Vectorscope: return "vectorscope";
case PanelId::VUMeter: return "vu";
case PanelId::LUFSMeter: return "lufs";
case PanelId::Spectrogram: return "spectrogram";
case PanelId::Waveform: return "waveform";
}
return LayoutNode::split(SplitAxis::Rows, {
LayoutNode::leaf(PanelId::Spectrum, 4),
LayoutNode::leaf(PanelId::VUMeter, 1),
});
return "spectrum";
}
std::optional<PanelId> parsePanelConfigName(const std::string& value) {
if (value == "spectrum") return PanelId::Spectrum;
if (value == "oscilloscope") return PanelId::Oscilloscope;
if (value == "vectorscope") return PanelId::Vectorscope;
if (value == "vu") return PanelId::VUMeter;
if (value == "lufs") return PanelId::LUFSMeter;
if (value == "spectrogram") return PanelId::Spectrogram;
if (value == "waveform") return PanelId::Waveform;
return std::nullopt;
}
size_t rackPanelCount(const RackLayout& rack) {
size_t result = 0;
for (const auto& row : rack.rows) result += row.tiles.size();
return result;
}
} // namespace
@@ -222,47 +217,302 @@ LayoutNode LayoutNode::split(SplitAxis axis,
DashboardLayout buildDashboardLayout(int width,
int height,
LayoutPreset requestedPreset,
const RackLayout& rawRack,
std::optional<PanelId> expandedPanel) {
DashboardLayout layout;
layout.requestedPreset = requestedPreset;
const RackLayout rack = normalizeRackLayout(rawRack);
layout.configuredRows = rack.rows.size();
layout.terminalTooSmall = width < kMinimumTerminalWidth || height < kMinimumTerminalHeight;
if (layout.terminalTooSmall) {
return layout;
}
layout.resolvedPreset = resolvePreset(requestedPreset, width, height);
layout.root = expandedPanel
? LayoutNode::leaf(*expandedPanel)
: makeRoot(layout.resolvedPreset, width, height);
if (expandedPanel) {
layout.root = LayoutNode::leaf(*expandedPanel);
layout.visibleRows = 1;
} else {
const int availableHeight = height - 2;
int requiredHeight = 0;
std::vector<LayoutNode> visibleRows;
for (size_t rowIndex = 0; rowIndex < rack.rows.size(); ++rowIndex) {
const auto& row = rack.rows[rowIndex];
const int rowMinimum = rackRowMinimumHeight(row);
if (!visibleRows.empty() &&
requiredHeight + rowMinimum > availableHeight) {
break;
}
const auto visibleTiles = visibleRackTiles(row, width);
std::vector<LayoutNode> tileNodes;
tileNodes.reserve(visibleTiles.size());
for (const auto& tile : visibleTiles) {
tileNodes.push_back(LayoutNode::leaf(tile.panel, tile.weight));
}
visibleRows.push_back(LayoutNode::split(
SplitAxis::Columns, std::move(tileNodes), row.weight));
requiredHeight += rowMinimum;
}
layout.visibleRows = visibleRows.size();
layout.hiddenRows = layout.configuredRows - layout.visibleRows;
layout.root = LayoutNode::split(SplitAxis::Rows, std::move(visibleRows));
}
// The header and footer each consume one terminal row.
resolveNode(layout.root, 0, 0, width, height - 2, layout.panels);
for (auto& panel : layout.panels) {
if (const auto location = rackPanelLocation(rack, panel.panel)) {
panel.rowIndex = location->first;
panel.tileIndex = location->second;
}
}
layout.hiddenPanels = expandedPanel
? 0
: rackPanelCount(rack) - layout.panels.size();
return layout;
}
LayoutPreset nextLayoutPreset(LayoutPreset preset) {
switch (preset) {
case LayoutPreset::Automatic:
return LayoutPreset::Stacked;
case LayoutPreset::Stacked:
return LayoutPreset::Columns;
case LayoutPreset::Columns:
return LayoutPreset::Automatic;
}
return LayoutPreset::Automatic;
RackLayout defaultRackLayout() {
return {{
{3, {
{PanelId::Spectrum, 2},
{PanelId::Oscilloscope, 3},
{PanelId::Vectorscope, 1},
}},
{2, {
{PanelId::Waveform, 3},
{PanelId::VUMeter, 1},
{PanelId::LUFSMeter, 1},
}},
{2, {
{PanelId::Spectrogram, 1},
}},
}};
}
std::string layoutPresetName(LayoutPreset preset) {
switch (preset) {
case LayoutPreset::Automatic:
return "auto";
case LayoutPreset::Stacked:
return "stacked";
case LayoutPreset::Columns:
return "columns";
RackLayout normalizeRackLayout(RackLayout rack) {
RackLayout result;
std::vector<PanelId> seen;
for (auto& row : rack.rows) {
if (result.rows.size() >= kMaximumRackRows) break;
RackRow normalizedRow;
normalizedRow.weight = std::clamp(
row.weight, 1, kMaximumRackWeight);
for (auto& tile : row.tiles) {
if (std::find(seen.begin(), seen.end(), tile.panel) != seen.end()) {
continue;
}
tile.weight = std::clamp(tile.weight, 1, kMaximumRackWeight);
normalizedRow.tiles.push_back(tile);
seen.push_back(tile.panel);
}
if (!normalizedRow.tiles.empty()) {
result.rows.push_back(std::move(normalizedRow));
}
}
return "auto";
return result.rows.empty() ? defaultRackLayout() : result;
}
std::string serializeRackLayout(const RackLayout& rawRack) {
const RackLayout rack = normalizeRackLayout(rawRack);
std::ostringstream output;
for (size_t rowIndex = 0; rowIndex < rack.rows.size(); ++rowIndex) {
if (rowIndex > 0) output << ';';
const auto& row = rack.rows[rowIndex];
output << row.weight << ':';
for (size_t tileIndex = 0; tileIndex < row.tiles.size(); ++tileIndex) {
if (tileIndex > 0) output << ',';
output << panelConfigName(row.tiles[tileIndex].panel)
<< '*' << row.tiles[tileIndex].weight;
}
}
return output.str();
}
RackLayout parseRackLayout(const std::string& value,
const RackLayout& fallback) {
RackLayout parsed;
std::stringstream rows(value);
std::string rowValue;
try {
while (std::getline(rows, rowValue, ';')) {
const size_t separator = rowValue.find(':');
if (separator == std::string::npos) continue;
RackRow row;
row.weight = std::stoi(rowValue.substr(0, separator));
std::stringstream tiles(rowValue.substr(separator + 1));
std::string tileValue;
while (std::getline(tiles, tileValue, ',')) {
const size_t weightSeparator = tileValue.find('*');
if (weightSeparator == std::string::npos) continue;
const auto panel = parsePanelConfigName(
tileValue.substr(0, weightSeparator));
if (!panel) continue;
row.tiles.push_back({
*panel,
std::stoi(tileValue.substr(weightSeparator + 1)),
});
}
if (!row.tiles.empty()) parsed.rows.push_back(std::move(row));
}
} catch (...) {
return normalizeRackLayout(fallback);
}
return parsed.rows.empty()
? normalizeRackLayout(fallback)
: normalizeRackLayout(std::move(parsed));
}
bool operator==(const RackTile& left, const RackTile& right) {
return left.panel == right.panel && left.weight == right.weight;
}
bool operator==(const RackRow& left, const RackRow& right) {
return left.weight == right.weight && left.tiles == right.tiles;
}
bool operator==(const RackLayout& left, const RackLayout& right) {
return left.rows == right.rows;
}
bool operator!=(const RackLayout& left, const RackLayout& right) {
return !(left == right);
}
std::optional<std::pair<size_t, size_t>> rackPanelLocation(
const RackLayout& rack,
PanelId panel) {
for (size_t rowIndex = 0; rowIndex < rack.rows.size(); ++rowIndex) {
const auto& row = rack.rows[rowIndex];
for (size_t tileIndex = 0; tileIndex < row.tiles.size(); ++tileIndex) {
if (row.tiles[tileIndex].panel == panel) {
return std::make_pair(rowIndex, tileIndex);
}
}
}
return std::nullopt;
}
std::vector<PanelId> configuredPanelOrder(const RackLayout& rack) {
std::vector<PanelId> result;
for (const auto& row : rack.rows) {
for (const auto& tile : row.tiles) result.push_back(tile.panel);
}
return result;
}
bool moveRackPanelHorizontal(RackLayout& rack,
PanelId panel,
int direction) {
rack = normalizeRackLayout(std::move(rack));
const RackLayout before = rack;
const auto location = rackPanelLocation(rack, panel);
if (!location || direction == 0) return false;
auto& tiles = rack.rows[location->first].tiles;
const int destination = static_cast<int>(location->second) +
(direction > 0 ? 1 : -1);
if (destination < 0 || destination >= static_cast<int>(tiles.size())) {
return false;
}
std::swap(tiles[location->second], tiles[static_cast<size_t>(destination)]);
return rack != before;
}
bool moveRackPanelVertical(RackLayout& rack,
PanelId panel,
int direction) {
rack = normalizeRackLayout(std::move(rack));
const RackLayout before = rack;
const auto location = rackPanelLocation(rack, panel);
if (!location || direction == 0) return false;
const int targetRow = static_cast<int>(location->first) +
(direction > 0 ? 1 : -1);
if (targetRow < 0 || targetRow >= static_cast<int>(rack.rows.size())) {
return false;
}
RackTile tile = rack.rows[location->first].tiles[location->second];
rack.rows[location->first].tiles.erase(
rack.rows[location->first].tiles.begin() +
static_cast<std::ptrdiff_t>(location->second));
size_t resolvedTarget = static_cast<size_t>(targetRow);
if (rack.rows[location->first].tiles.empty()) {
rack.rows.erase(rack.rows.begin() +
static_cast<std::ptrdiff_t>(location->first));
if (location->first < resolvedTarget) --resolvedTarget;
}
auto& targetTiles = rack.rows[resolvedTarget].tiles;
const size_t insertion = std::min(location->second, targetTiles.size());
targetTiles.insert(
targetTiles.begin() + static_cast<std::ptrdiff_t>(insertion), tile);
rack = normalizeRackLayout(std::move(rack));
return rack != before;
}
bool resizeRackPanel(RackLayout& rack, PanelId panel, int direction) {
rack = normalizeRackLayout(std::move(rack));
const RackLayout before = rack;
const auto location = rackPanelLocation(rack, panel);
if (!location || direction == 0) return false;
auto& weight = rack.rows[location->first].tiles[location->second].weight;
weight = std::clamp(
weight + (direction > 0 ? 1 : -1), 1, kMaximumRackWeight);
return rack != before;
}
bool resizeRackRow(RackLayout& rack, PanelId panel, int direction) {
rack = normalizeRackLayout(std::move(rack));
const RackLayout before = rack;
const auto location = rackPanelLocation(rack, panel);
if (!location || direction == 0) return false;
auto& weight = rack.rows[location->first].weight;
weight = std::clamp(
weight + (direction > 0 ? 1 : -1), 1, kMaximumRackWeight);
return rack != before;
}
bool splitRackRow(RackLayout& rack, PanelId panel) {
rack = normalizeRackLayout(std::move(rack));
const RackLayout before = rack;
const auto location = rackPanelLocation(rack, panel);
if (!location || rack.rows.size() >= kMaximumRackRows ||
rack.rows[location->first].tiles.size() <= 1) {
return false;
}
RackTile tile = rack.rows[location->first].tiles[location->second];
rack.rows[location->first].tiles.erase(
rack.rows[location->first].tiles.begin() +
static_cast<std::ptrdiff_t>(location->second));
rack.rows.insert(
rack.rows.begin() + static_cast<std::ptrdiff_t>(location->first + 1),
RackRow{1, {tile}});
rack = normalizeRackLayout(std::move(rack));
return rack != before;
}
bool removeRackPanel(RackLayout& rack, PanelId panel) {
rack = normalizeRackLayout(std::move(rack));
const RackLayout before = rack;
if (configuredPanelOrder(rack).size() <= 1) return false;
const auto location = rackPanelLocation(rack, panel);
if (!location) return false;
auto& tiles = rack.rows[location->first].tiles;
tiles.erase(tiles.begin() + static_cast<std::ptrdiff_t>(location->second));
rack = normalizeRackLayout(std::move(rack));
return rack != before;
}
bool addRackPanel(RackLayout& rack, PanelId panel, PanelId afterPanel) {
rack = normalizeRackLayout(std::move(rack));
const RackLayout before = rack;
if (rackPanelLocation(rack, panel)) return false;
const auto target = rackPanelLocation(rack, afterPanel);
if (!target) return false;
auto& tiles = rack.rows[target->first].tiles;
tiles.insert(
tiles.begin() + static_cast<std::ptrdiff_t>(target->second + 1),
RackTile{panel, 1});
rack = normalizeRackLayout(std::move(rack));
return rack != before;
}
std::vector<PanelId> panelOrder() {
@@ -300,10 +550,8 @@ PanelId nextPanel(PanelId panel,
std::vector<PanelId> visiblePanelOrder(const DashboardLayout& layout) {
std::vector<PanelId> result;
for (const auto panel : panelOrder()) {
if (layoutContainsPanel(layout, panel)) {
result.push_back(panel);
}
for (const auto& panel : layout.panels) {
result.push_back(panel.panel);
}
return result;
}
@@ -315,4 +563,79 @@ bool layoutContainsPanel(const DashboardLayout& layout, PanelId panel) {
[panel](const PanelRect& rect) { return rect.panel == panel; });
}
std::optional<PanelId> spatialNeighbor(const DashboardLayout& layout,
PanelId panel,
NavigationDirection direction) {
const auto current = std::find_if(
layout.panels.begin(),
layout.panels.end(),
[panel](const PanelRect& rect) { return rect.panel == panel; });
if (current == layout.panels.end()) return std::nullopt;
if (direction == NavigationDirection::Left ||
direction == NavigationDirection::Right) {
const PanelRect* best = nullptr;
for (const auto& candidate : layout.panels) {
if (candidate.rowIndex != current->rowIndex) continue;
const bool inDirection = direction == NavigationDirection::Left
? candidate.tileIndex < current->tileIndex
: candidate.tileIndex > current->tileIndex;
if (!inDirection) continue;
if (best == nullptr ||
(direction == NavigationDirection::Left
? candidate.tileIndex > best->tileIndex
: candidate.tileIndex < best->tileIndex)) {
best = &candidate;
}
}
return best == nullptr
? std::nullopt
: std::optional<PanelId>{best->panel};
}
std::optional<size_t> targetRow;
for (const auto& candidate : layout.panels) {
const bool inDirection = direction == NavigationDirection::Up
? candidate.rowIndex < current->rowIndex
: candidate.rowIndex > current->rowIndex;
if (!inDirection) continue;
if (!targetRow ||
(direction == NavigationDirection::Up
? candidate.rowIndex > *targetRow
: candidate.rowIndex < *targetRow)) {
targetRow = candidate.rowIndex;
}
}
if (!targetRow) return std::nullopt;
const int currentLeft = current->x;
const int currentRight = current->x + current->width;
const int currentCenterTwice = currentLeft + currentRight;
const PanelRect* best = nullptr;
int bestOverlap = -1;
int bestCenterDistance = 0;
for (const auto& candidate : layout.panels) {
if (candidate.rowIndex != *targetRow) continue;
const int candidateLeft = candidate.x;
const int candidateRight = candidate.x + candidate.width;
const int overlap = std::max(
0,
std::min(currentRight, candidateRight) -
std::max(currentLeft, candidateLeft));
const int centerDistance = std::abs(
currentCenterTwice - (candidateLeft + candidateRight));
if (best == nullptr || overlap > bestOverlap ||
(overlap == bestOverlap && centerDistance < bestCenterDistance) ||
(overlap == bestOverlap && centerDistance == bestCenterDistance &&
candidate.tileIndex < best->tileIndex)) {
best = &candidate;
bestOverlap = overlap;
bestCenterDistance = centerDistance;
}
}
return best == nullptr
? std::nullopt
: std::optional<PanelId>{best->panel};
}
} // namespace Prism::Tui
+52 -9
View File
@@ -2,6 +2,7 @@
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace Prism::Tui {
@@ -21,10 +22,25 @@ enum class SplitAxis {
Columns,
};
enum class LayoutPreset {
Automatic,
Stacked,
Columns,
enum class NavigationDirection {
Left,
Right,
Up,
Down,
};
struct RackTile {
PanelId panel = PanelId::Spectrum;
int weight = 1;
};
struct RackRow {
int weight = 1;
std::vector<RackTile> tiles;
};
struct RackLayout {
std::vector<RackRow> rows;
};
struct LayoutNode {
@@ -42,6 +58,8 @@ struct LayoutNode {
struct PanelRect {
PanelId panel = PanelId::Spectrum;
size_t rowIndex = 0;
size_t tileIndex = 0;
int x = 0;
int y = 0;
int width = 0;
@@ -50,21 +68,43 @@ struct PanelRect {
struct DashboardLayout {
bool terminalTooSmall = true;
LayoutPreset requestedPreset = LayoutPreset::Automatic;
LayoutPreset resolvedPreset = LayoutPreset::Stacked;
size_t configuredRows = 0;
size_t visibleRows = 0;
size_t hiddenRows = 0;
size_t hiddenPanels = 0;
LayoutNode root;
std::vector<PanelRect> panels;
};
constexpr int kMinimumTerminalWidth = 44;
constexpr int kMinimumTerminalHeight = 12;
constexpr size_t kMaximumRackRows = 3;
constexpr int kMaximumRackWeight = 8;
DashboardLayout buildDashboardLayout(int width,
int height,
LayoutPreset requestedPreset,
const RackLayout& rack,
std::optional<PanelId> expandedPanel = std::nullopt);
LayoutPreset nextLayoutPreset(LayoutPreset preset);
std::string layoutPresetName(LayoutPreset preset);
RackLayout defaultRackLayout();
RackLayout normalizeRackLayout(RackLayout rack);
std::string serializeRackLayout(const RackLayout& rack);
RackLayout parseRackLayout(const std::string& value,
const RackLayout& fallback);
bool operator==(const RackTile& left, const RackTile& right);
bool operator==(const RackRow& left, const RackRow& right);
bool operator==(const RackLayout& left, const RackLayout& right);
bool operator!=(const RackLayout& left, const RackLayout& right);
std::optional<std::pair<size_t, size_t>> rackPanelLocation(
const RackLayout& rack,
PanelId panel);
std::vector<PanelId> configuredPanelOrder(const RackLayout& rack);
bool moveRackPanelHorizontal(RackLayout& rack, PanelId panel, int direction);
bool moveRackPanelVertical(RackLayout& rack, PanelId panel, int direction);
bool resizeRackPanel(RackLayout& rack, PanelId panel, int direction);
bool resizeRackRow(RackLayout& rack, PanelId panel, int direction);
bool splitRackRow(RackLayout& rack, PanelId panel);
bool removeRackPanel(RackLayout& rack, PanelId panel);
bool addRackPanel(RackLayout& rack, PanelId panel, PanelId afterPanel);
std::vector<PanelId> panelOrder();
PanelId nextPanel(PanelId panel, bool reverse = false);
PanelId nextPanel(PanelId panel,
@@ -72,5 +112,8 @@ PanelId nextPanel(PanelId panel,
bool reverse = false);
std::vector<PanelId> visiblePanelOrder(const DashboardLayout& layout);
bool layoutContainsPanel(const DashboardLayout& layout, PanelId panel);
std::optional<PanelId> spatialNeighbor(const DashboardLayout& layout,
PanelId panel,
NavigationDirection direction);
} // namespace Prism::Tui
+443
View File
@@ -0,0 +1,443 @@
#include "profile_library.h"
#include <algorithm>
#include <chrono>
#include <cctype>
#include <fstream>
#include <iomanip>
#include <iterator>
#include <random>
#include <sstream>
#include <system_error>
namespace Prism::Tui {
namespace {
constexpr const char* kProfileFormat = "prism-tui-profile";
constexpr int kProfileVersion = 1;
std::string readFile(const std::filesystem::path& path) {
std::ifstream input(path, std::ios::binary);
if (!input) return {};
return std::string(
std::istreambuf_iterator<char>(input),
std::istreambuf_iterator<char>());
}
std::string metadataValue(const std::string& text, const std::string& key) {
std::istringstream input(text);
std::string line;
while (std::getline(input, line)) {
const size_t separator = line.find('=');
if (separator == std::string::npos || line.substr(0, separator) != key) {
continue;
}
return line.substr(separator + 1);
}
return {};
}
std::string decodeQuoted(const std::string& value) {
std::istringstream input(value);
std::string decoded;
if (input >> std::quoted(decoded)) return decoded;
return value;
}
std::string trimName(std::string name) {
name.erase(
name.begin(),
std::find_if(name.begin(), name.end(), [](unsigned char character) {
return !std::isspace(character);
}));
name.erase(
std::find_if(name.rbegin(), name.rend(), [](unsigned char character) {
return !std::isspace(character);
}).base(),
name.end());
name.erase(
std::remove_if(name.begin(), name.end(), [](unsigned char character) {
return character < 0x20 || character == 0x7f;
}),
name.end());
if (name.size() > 64) name.resize(64);
return name;
}
std::string lowercaseAscii(std::string value) {
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
return static_cast<char>(std::tolower(c));
});
return value;
}
bool validProfileId(const std::string& id) {
return !id.empty() && id.size() <= 96 &&
std::all_of(id.begin(), id.end(), [](unsigned char character) {
return std::isalnum(character) || character == '_' || character == '-';
});
}
std::string profileText(const TuiProfile& profile) {
std::ostringstream output;
output << "format=" << kProfileFormat << '\n'
<< "version=" << kProfileVersion << '\n'
<< "id=" << profile.id << '\n'
<< "name=" << std::quoted(profile.name) << '\n'
<< serializeSettingsText(profile.settings, false);
return output.str();
}
bool writeFile(const std::filesystem::path& path,
const std::string& text,
std::string* error) {
std::error_code filesystemError;
if (!path.parent_path().empty()) {
std::filesystem::create_directories(path.parent_path(), filesystemError);
if (filesystemError) {
if (error) *error = filesystemError.message();
return false;
}
}
std::filesystem::path temporary = path;
temporary += ".tmp";
{
std::ofstream output(temporary, std::ios::binary | std::ios::trunc);
if (!output) {
if (error) *error = "could not open temporary profile file";
return false;
}
output << text;
if (!output) {
output.close();
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
if (error) *error = "could not write profile file";
return false;
}
}
std::filesystem::rename(temporary, path, filesystemError);
#if defined(_WIN32)
if (filesystemError && std::filesystem::exists(path)) {
filesystemError.clear();
std::filesystem::remove(path, filesystemError);
if (!filesystemError) {
std::filesystem::rename(temporary, path, filesystemError);
}
}
#endif
if (filesystemError) {
std::error_code ignored;
std::filesystem::remove(temporary, ignored);
if (error) *error = filesystemError.message();
return false;
}
return true;
}
std::optional<TuiProfile> parseProfile(const std::string& text,
std::string* error) {
if (metadataValue(text, "format") != kProfileFormat) {
if (error) *error = "unsupported profile format";
return std::nullopt;
}
if (metadataValue(text, "version") != std::to_string(kProfileVersion)) {
if (error) *error = "unsupported profile version";
return std::nullopt;
}
TuiProfile profile;
profile.id = metadataValue(text, "id");
profile.name = trimName(decodeQuoted(metadataValue(text, "name")));
if (!validProfileId(profile.id) || profile.name.empty()) {
if (error) *error = "profile metadata is invalid";
return std::nullopt;
}
profile.settings = parseSettingsText(text);
profile.isDefault = profile.id == kDefaultTuiProfileId;
return profile;
}
std::string filenameStem(std::string name) {
name = trimName(std::move(name));
for (char& character : name) {
const unsigned char byte = static_cast<unsigned char>(character);
if (byte < 0x20 || character == '/' || character == '\\' ||
character == ':' || character == '*' || character == '?' ||
character == '"' || character == '<' || character == '>' ||
character == '|') {
character = '_';
}
}
while (!name.empty() && (name.back() == ' ' || name.back() == '.')) {
name.pop_back();
}
return name.empty() ? "Profile" : name;
}
std::string generateProfileId() {
static std::mt19937_64 generator(std::random_device{}());
const auto timestamp = std::chrono::high_resolution_clock::now()
.time_since_epoch().count();
std::ostringstream output;
output << "profile_" << std::hex << timestamp << generator();
return output.str();
}
} // namespace
TuiProfileLibrary::TuiProfileLibrary(std::filesystem::path directory,
std::filesystem::path statePath)
: directory_(std::move(directory)), statePath_(std::move(statePath)) {}
bool TuiProfileLibrary::load(std::string* error) {
std::error_code filesystemError;
std::filesystem::create_directories(directory_, filesystemError);
if (filesystemError) {
if (error) *error = filesystemError.message();
return false;
}
if (!reloadProfiles(error)) return false;
if (!findManaged(kDefaultTuiProfileId)) {
TuiProfile defaultProfile{
kDefaultTuiProfileId,
"Default",
TuiSettings{},
true,
};
const auto path = uniqueProfilePath(defaultProfile.name);
if (!writeFile(path, profileText(defaultProfile), error)) return false;
if (!reloadProfiles(error)) return false;
}
activeProfileId_ = metadataValue(readFile(statePath_), "active_profile_id");
if (!activeProfileId_.empty() && !findManaged(activeProfileId_)) {
activeProfileId_.clear();
}
publishProfiles();
return true;
}
bool TuiProfileLibrary::reloadProfiles(std::string* error) {
managed_.clear();
std::error_code filesystemError;
for (const auto& entry : std::filesystem::directory_iterator(
directory_, filesystemError)) {
if (filesystemError) break;
if (!entry.is_regular_file() ||
lowercaseAscii(entry.path().extension().string()) != kTuiProfileExtension) {
continue;
}
std::string parseError;
const auto parsed = parseProfile(readFile(entry.path()), &parseError);
if (!parsed || findManaged(parsed->id)) continue;
managed_.push_back({*parsed, entry.path()});
}
if (filesystemError) {
if (error) *error = filesystemError.message();
return false;
}
std::sort(managed_.begin(), managed_.end(), [](const auto& left, const auto& right) {
if (left.profile.isDefault != right.profile.isDefault) {
return left.profile.isDefault;
}
return lowercaseAscii(left.profile.name) < lowercaseAscii(right.profile.name);
});
publishProfiles();
return true;
}
const TuiProfile* TuiProfileLibrary::find(const std::string& id) const {
const auto* managed = findManaged(id);
return managed ? &managed->profile : nullptr;
}
TuiProfileLibrary::ManagedProfile* TuiProfileLibrary::findManaged(
const std::string& id) {
const auto found = std::find_if(
managed_.begin(), managed_.end(), [&](const auto& entry) {
return entry.profile.id == id;
});
return found == managed_.end() ? nullptr : &*found;
}
const TuiProfileLibrary::ManagedProfile* TuiProfileLibrary::findManaged(
const std::string& id) const {
const auto found = std::find_if(
managed_.begin(), managed_.end(), [&](const auto& entry) {
return entry.profile.id == id;
});
return found == managed_.end() ? nullptr : &*found;
}
bool TuiProfileLibrary::writeActiveState(std::string* error) const {
return writeFile(
statePath_, "active_profile_id=" + activeProfileId_ + "\n", error);
}
bool TuiProfileLibrary::activate(const std::string& id, std::string* error) {
if (!findManaged(id)) {
if (error) *error = "profile was not found";
return false;
}
activeProfileId_ = id;
return writeActiveState(error);
}
bool TuiProfileLibrary::nameIsAvailable(
const std::string& name,
const std::string& excludingId) const {
const std::string expected = lowercaseAscii(trimName(name));
return std::none_of(managed_.begin(), managed_.end(), [&](const auto& entry) {
return entry.profile.id != excludingId &&
lowercaseAscii(entry.profile.name) == expected;
});
}
std::filesystem::path TuiProfileLibrary::uniqueProfilePath(
const std::string& name,
const std::filesystem::path& current) const {
const std::string stem = filenameStem(name);
for (int suffix = 0; suffix < 10000; ++suffix) {
const std::string filename = stem +
(suffix == 0 ? "" : " " + std::to_string(suffix + 1)) +
kTuiProfileExtension;
const auto candidate = directory_ / filename;
if (candidate == current || !std::filesystem::exists(candidate)) {
return candidate;
}
}
return directory_ / (generateProfileId() + kTuiProfileExtension);
}
bool TuiProfileLibrary::saveNew(const std::string& rawName,
const TuiSettings& settings,
std::string* createdId,
std::string* error) {
const std::string name = trimName(rawName);
if (name.empty()) {
if (error) *error = "profile name cannot be empty";
return false;
}
if (!nameIsAvailable(name)) {
if (error) *error = "a profile with that name already exists";
return false;
}
std::string id;
do {
id = generateProfileId();
} while (findManaged(id));
TuiProfile profile{id, name, normalizeSettings(settings), false};
const auto path = uniqueProfilePath(name);
if (!writeFile(path, profileText(profile), error)) return false;
if (!reloadProfiles(error)) return false;
activeProfileId_ = id;
if (!writeActiveState(error)) return false;
if (createdId) *createdId = id;
return true;
}
bool TuiProfileLibrary::overwrite(const std::string& id,
const TuiSettings& settings,
std::string* error) {
auto* entry = findManaged(id);
if (!entry) {
if (error) *error = "profile was not found";
return false;
}
TuiProfile updated = entry->profile;
updated.settings = normalizeSettings(settings);
if (!writeFile(entry->path, profileText(updated), error)) return false;
return reloadProfiles(error);
}
bool TuiProfileLibrary::renameProfile(const std::string& id,
const std::string& rawName,
std::string* error) {
auto* entry = findManaged(id);
if (!entry) {
if (error) *error = "profile was not found";
return false;
}
if (entry->profile.isDefault) {
if (error) *error = "the default profile cannot be renamed";
return false;
}
const std::string name = trimName(rawName);
if (name.empty()) {
if (error) *error = "profile name cannot be empty";
return false;
}
if (!nameIsAvailable(name, id)) {
if (error) *error = "a profile with that name already exists";
return false;
}
const auto oldPath = entry->path;
const auto nextPath = uniqueProfilePath(name, oldPath);
TuiProfile updated = entry->profile;
updated.name = name;
if (!writeFile(nextPath, profileText(updated), error)) return false;
if (nextPath != oldPath) {
std::error_code filesystemError;
std::filesystem::remove(oldPath, filesystemError);
if (filesystemError) {
if (error) *error = filesystemError.message();
return false;
}
}
return reloadProfiles(error);
}
bool TuiProfileLibrary::deleteProfile(const std::string& id,
std::string* error) {
auto* entry = findManaged(id);
if (!entry) {
if (error) *error = "profile was not found";
return false;
}
if (entry->profile.isDefault) {
if (error) *error = "the default profile cannot be deleted";
return false;
}
std::error_code filesystemError;
std::filesystem::remove(entry->path, filesystemError);
if (filesystemError) {
if (error) *error = filesystemError.message();
return false;
}
if (activeProfileId_ == id) {
activeProfileId_.clear();
if (!writeActiveState(error)) return false;
}
return reloadProfiles(error);
}
void TuiProfileLibrary::publishProfiles() {
profiles_.clear();
profiles_.reserve(managed_.size());
for (const auto& entry : managed_) profiles_.push_back(entry.profile);
}
std::filesystem::path defaultProfileDirectory() {
return defaultSettingsPath().parent_path() / "tui-profiles";
}
std::filesystem::path defaultProfileStatePath() {
return defaultSettingsPath().parent_path() / "tui-profile-state.conf";
}
bool profileSettingsEqual(const TuiSettings& left,
const TuiSettings& right) {
TuiSettings normalizedLeft = normalizeSettings(left);
TuiSettings normalizedRight = normalizeSettings(right);
normalizedLeft.refreshRate = normalizedRight.refreshRate;
return normalizedLeft == normalizedRight;
}
TuiSettings applyProfileSettings(const TuiSettings& profile,
const TuiSettings& working) {
TuiSettings applied = normalizeSettings(profile);
applied.refreshRate = normalizeSettings(working).refreshRate;
return applied;
}
} // namespace Prism::Tui
+76
View File
@@ -0,0 +1,76 @@
#pragma once
#include "tui_settings.h"
#include <filesystem>
#include <string>
#include <vector>
namespace Prism::Tui {
constexpr const char* kTuiProfileExtension = ".prsmt";
constexpr const char* kDefaultTuiProfileId = "profile_default";
struct TuiProfile {
std::string id;
std::string name;
TuiSettings settings;
bool isDefault = false;
};
class TuiProfileLibrary {
public:
TuiProfileLibrary(std::filesystem::path directory,
std::filesystem::path statePath);
bool load(std::string* error = nullptr);
const std::vector<TuiProfile>& profiles() const { return profiles_; }
const std::string& activeProfileId() const { return activeProfileId_; }
const TuiProfile* find(const std::string& id) const;
bool activate(const std::string& id, std::string* error = nullptr);
bool saveNew(const std::string& name,
const TuiSettings& settings,
std::string* createdId = nullptr,
std::string* error = nullptr);
bool overwrite(const std::string& id,
const TuiSettings& settings,
std::string* error = nullptr);
bool renameProfile(const std::string& id,
const std::string& name,
std::string* error = nullptr);
bool deleteProfile(const std::string& id,
std::string* error = nullptr);
private:
struct ManagedProfile {
TuiProfile profile;
std::filesystem::path path;
};
bool reloadProfiles(std::string* error);
bool writeActiveState(std::string* error) const;
ManagedProfile* findManaged(const std::string& id);
const ManagedProfile* findManaged(const std::string& id) const;
bool nameIsAvailable(const std::string& name,
const std::string& excludingId = {}) const;
std::filesystem::path uniqueProfilePath(
const std::string& name,
const std::filesystem::path& current = {}) const;
void publishProfiles();
std::filesystem::path directory_;
std::filesystem::path statePath_;
std::vector<ManagedProfile> managed_;
std::vector<TuiProfile> profiles_;
std::string activeProfileId_;
};
std::filesystem::path defaultProfileDirectory();
std::filesystem::path defaultProfileStatePath();
bool profileSettingsEqual(const TuiSettings& left,
const TuiSettings& right);
TuiSettings applyProfileSettings(const TuiSettings& profile,
const TuiSettings& working);
} // namespace Prism::Tui
+1107 -53
View File
File diff suppressed because it is too large Load Diff
+43 -47
View File
@@ -5,8 +5,10 @@
#include <cstdlib>
#include <fstream>
#include <iomanip>
#include <iterator>
#include <sstream>
#include <system_error>
#include <utility>
namespace Prism::Tui {
namespace {
@@ -14,7 +16,6 @@ namespace {
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."},
{SettingId::Layout, "Dashboard layout", "Chooses automatic, stacked, or column panes."},
};
const std::vector<SettingDescriptor> kSpectrumSettings = {
@@ -75,10 +76,6 @@ std::string trimFloat(float value, int precision) {
return output.str();
}
std::string serializeLayout(LayoutPreset layout) {
return layoutPresetName(layout);
}
std::string serializeVectorMode(VectorscopeMode mode) {
switch (mode) {
case VectorscopeMode::Lissajous: return "lissajous";
@@ -178,13 +175,6 @@ int parseInt(const std::string& value, int fallback) {
}
}
LayoutPreset parseLayout(const std::string& value, LayoutPreset fallback) {
if (value == "auto") return LayoutPreset::Automatic;
if (value == "stacked") return LayoutPreset::Stacked;
if (value == "columns") return LayoutPreset::Columns;
return fallback;
}
VectorscopeMode parseVectorMode(const std::string& value, VectorscopeMode fallback) {
if (value == "lissajous") return VectorscopeMode::Lissajous;
if (value == "polar_unipolar") return VectorscopeMode::PolarUnipolar;
@@ -307,6 +297,7 @@ const char* waveformModeName(WaveformMode mode) {
TuiSettings normalizeSettings(TuiSettings settings) {
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));
settings.spectrumTiltDbPerOctave = std::clamp(
snap(settings.spectrumTiltDbPerOctave, 0.1f), -2.0f, 8.0f);
settings.oscilloscopeTraceWeight = std::clamp(settings.oscilloscopeTraceWeight, 1, 3);
@@ -328,7 +319,7 @@ TuiSettings normalizeSettings(TuiSettings settings) {
bool operator==(const TuiSettings& left, const TuiSettings& right) {
return left.inputTrimDb == right.inputTrimDb &&
left.refreshRate == right.refreshRate &&
left.layoutPreset == right.layoutPreset &&
left.rackLayout == right.rackLayout &&
left.spectrumPeakReadout == right.spectrumPeakReadout &&
left.spectrumTiltDbPerOctave == right.spectrumTiltDbPerOctave &&
left.oscilloscopePitchLock == right.oscilloscopePitchLock &&
@@ -424,8 +415,6 @@ std::string settingValue(const TuiSettings& settings, SettingId setting) {
trimFloat(settings.inputTrimDb, 1) + " dB";
case SettingId::RefreshRate:
return std::to_string(settings.refreshRate) + " FPS";
case SettingId::Layout:
return layoutPresetName(settings.layoutPreset);
case SettingId::SpectrumPeakReadout:
return boolValue(settings.spectrumPeakReadout);
case SettingId::SpectrumTilt:
@@ -498,17 +487,6 @@ bool adjustSetting(TuiSettings& settings, SettingId setting, int direction) {
case SettingId::RefreshRate:
settings.refreshRate = settings.refreshRate == 60 ? 30 : 60;
break;
case SettingId::Layout:
if (direction > 0) {
settings.layoutPreset = nextLayoutPreset(settings.layoutPreset);
} else {
settings.layoutPreset = settings.layoutPreset == LayoutPreset::Automatic
? LayoutPreset::Columns
: settings.layoutPreset == LayoutPreset::Columns
? LayoutPreset::Stacked
: LayoutPreset::Automatic;
}
break;
case SettingId::SpectrumPeakReadout:
settings.spectrumPeakReadout = !settings.spectrumPeakReadout;
break;
@@ -628,7 +606,6 @@ bool resetSetting(TuiSettings& settings, SettingId setting) {
switch (setting) {
case SettingId::InputTrim: settings.inputTrimDb = defaults.inputTrimDb; break;
case SettingId::RefreshRate: settings.refreshRate = defaults.refreshRate; break;
case SettingId::Layout: settings.layoutPreset = defaults.layoutPreset; break;
case SettingId::SpectrumPeakReadout: settings.spectrumPeakReadout = defaults.spectrumPeakReadout; break;
case SettingId::SpectrumTilt: settings.spectrumTiltDbPerOctave = defaults.spectrumTiltDbPerOctave; break;
case SettingId::OscilloscopePitchLock: settings.oscilloscopePitchLock = defaults.oscilloscopePitchLock; break;
@@ -678,8 +655,18 @@ std::filesystem::path defaultSettingsPath() {
}
TuiSettings loadSettings(const std::filesystem::path& path) {
TuiSettings settings;
std::ifstream input(path);
if (!input) return {};
return parseSettingsText(
std::string(
std::istreambuf_iterator<char>(input),
std::istreambuf_iterator<char>()));
}
TuiSettings parseSettingsText(const std::string& text,
const TuiSettings& fallback) {
TuiSettings settings = fallback;
std::istringstream input(text);
std::string line;
while (std::getline(input, line)) {
const size_t separator = line.find('=');
@@ -688,7 +675,7 @@ TuiSettings loadSettings(const std::filesystem::path& path) {
const std::string value = line.substr(separator + 1);
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 == "layout") settings.layoutPreset = parseLayout(value, settings.layoutPreset);
else if (key == "rack_layout") settings.rackLayout = parseRackLayout(value, settings.rackLayout);
else if (key == "spectrum_peak") settings.spectrumPeakReadout = parseBool(value, settings.spectrumPeakReadout);
else if (key == "spectrum_tilt") settings.spectrumTiltDbPerOctave = parseFloat(value, settings.spectrumTiltDbPerOctave);
else if (key == "osc_pitch_lock") settings.oscilloscopePitchLock = parseBool(value, settings.oscilloscopePitchLock);
@@ -716,26 +703,15 @@ TuiSettings loadSettings(const std::filesystem::path& path) {
return normalizeSettings(settings);
}
bool saveSettings(const TuiSettings& rawSettings,
const std::filesystem::path& path,
std::string* error) {
std::string serializeSettingsText(const TuiSettings& rawSettings,
bool includeRefreshRate) {
const TuiSettings settings = normalizeSettings(rawSettings);
std::error_code filesystemError;
if (!path.parent_path().empty()) {
std::filesystem::create_directories(path.parent_path(), filesystemError);
if (filesystemError) {
if (error) *error = filesystemError.message();
return false;
}
std::ostringstream output;
output << "input_trim_db=" << settings.inputTrimDb << '\n';
if (includeRefreshRate) {
output << "refresh_rate=" << settings.refreshRate << '\n';
}
std::ofstream output(path, std::ios::trunc);
if (!output) {
if (error) *error = "could not open settings file";
return false;
}
output << "input_trim_db=" << settings.inputTrimDb << '\n'
<< "refresh_rate=" << settings.refreshRate << '\n'
<< "layout=" << serializeLayout(settings.layoutPreset) << '\n'
output << "rack_layout=" << serializeRackLayout(settings.rackLayout) << '\n'
<< "spectrum_peak=" << (settings.spectrumPeakReadout ? "true" : "false") << '\n'
<< "spectrum_tilt=" << settings.spectrumTiltDbPerOctave << '\n'
<< "osc_pitch_lock=" << (settings.oscilloscopePitchLock ? "true" : "false") << '\n'
@@ -759,6 +735,26 @@ bool saveSettings(const TuiSettings& rawSettings,
<< "waveform_mode=" << serializeWaveformMode(settings.waveformMode) << '\n'
<< "waveform_scroll=" << settings.waveformScrollSpeed << '\n'
<< "waveform_multiband=" << (settings.waveformMultiband ? "true" : "false") << '\n';
return output.str();
}
bool saveSettings(const TuiSettings& rawSettings,
const std::filesystem::path& path,
std::string* error) {
std::error_code filesystemError;
if (!path.parent_path().empty()) {
std::filesystem::create_directories(path.parent_path(), filesystemError);
if (filesystemError) {
if (error) *error = filesystemError.message();
return false;
}
}
std::ofstream output(path, std::ios::trunc);
if (!output) {
if (error) *error = "could not open settings file";
return false;
}
output << serializeSettingsText(rawSettings);
if (!output) {
if (error) *error = "could not write settings file";
return false;
+5 -2
View File
@@ -25,7 +25,6 @@ enum class SettingsPage {
enum class SettingId {
InputTrim,
RefreshRate,
Layout,
SpectrumPeakReadout,
SpectrumTilt,
OscilloscopePitchLock,
@@ -66,7 +65,7 @@ enum class WaveformMode { Mono, Stereo };
struct TuiSettings {
float inputTrimDb = 0.0f;
int refreshRate = 60;
LayoutPreset layoutPreset = LayoutPreset::Automatic;
RackLayout rackLayout = defaultRackLayout();
bool spectrumPeakReadout = true;
float spectrumTiltDbPerOctave = 2.0f;
bool oscilloscopePitchLock = true;
@@ -119,6 +118,10 @@ const char* spectrogramOrientationName(SpectrogramOrientation orientation);
const char* waveformModeName(WaveformMode mode);
std::filesystem::path defaultSettingsPath();
TuiSettings parseSettingsText(const std::string& text,
const TuiSettings& fallback = TuiSettings{});
std::string serializeSettingsText(const TuiSettings& settings,
bool includeRefreshRate = true);
TuiSettings loadSettings(const std::filesystem::path& path);
bool saveSettings(const TuiSettings& settings,
const std::filesystem::path& path,
+229 -60
View File
@@ -3,6 +3,7 @@
#include "dashboard_layout.h"
#include "display_model.h"
#include "meter_display_model.h"
#include "profile_library.h"
#include "scope_plot_model.h"
#include "scrolling_history.h"
#include "snapshot_store.h"
@@ -15,7 +16,9 @@
#include <cstdlib>
#include <deque>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <iterator>
#include <memory>
#include <string>
#include <thread>
@@ -113,8 +116,10 @@ void testCli() {
"exclusive commands should not combine");
require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos,
"help should describe dashboard keyboard controls");
require(Prism::Tui::usageText().find("Cycle vectorscope") != std::string::npos,
"help should describe vectorscope mode controls");
require(Prism::Tui::usageText().find("Cycle vectorscope") == std::string::npos,
"help should not advertise the removed vectorscope shortcut");
require(Prism::Tui::usageText().find("Open profiles") != std::string::npos,
"help should describe the profile library shortcut");
}
void testProjectionAndLayout() {
@@ -152,56 +157,100 @@ void testProjectionAndLayout() {
require(meter.find("") != std::string::npos,
"meter bar should include its peak marker");
const auto wide = Prism::Tui::buildDashboardLayout(
100, 30, Prism::Tui::LayoutPreset::Automatic);
require(!wide.terminalTooSmall &&
wide.resolvedPreset == Prism::Tui::LayoutPreset::Columns,
"wide, tall terminals should use the columns dashboard");
require(wide.panels.size() == 5 &&
wide.panels[0].panel == Prism::Tui::PanelId::Spectrum &&
wide.panels[1].panel == Prism::Tui::PanelId::Oscilloscope &&
wide.panels[2].panel == Prism::Tui::PanelId::Vectorscope &&
wide.panels[3].panel == Prism::Tui::PanelId::VUMeter &&
wide.panels[4].panel == Prism::Tui::PanelId::LUFSMeter,
"the dashboard should contain all five scope panels");
require(wide.panels[0].width == wide.panels[1].width &&
wide.panels[2].width == wide.panels[3].width &&
wide.panels[3].width == wide.panels[4].width &&
wide.panels[0].width + wide.panels[2].width == 100 &&
wide.panels[0].width > wide.panels[2].width,
"dashboard columns should fill the width and favor visual plots");
require(wide.panels[0].height + wide.panels[1].height == 28 &&
wide.panels[2].height + wide.panels[3].height +
wide.panels[4].height == 28,
"both dashboard columns should fill the available height");
const auto rack = Prism::Tui::defaultRackLayout();
const auto full = Prism::Tui::buildDashboardLayout(140, 44, rack);
require(!full.terminalTooSmall && full.configuredRows == 3 &&
full.visibleRows == 3 && full.hiddenRows == 0 &&
full.hiddenPanels == 0 && full.panels.size() == 7,
"large dashboards should show the complete three-row scope rack");
require(Prism::Tui::visiblePanelOrder(full) ==
Prism::Tui::configuredPanelOrder(rack),
"the dashboard should preserve rack row and tile order");
require(std::all_of(full.panels.begin(), full.panels.end(), [](const auto& panel) {
return panel.width >= 30 && panel.height >= 5;
}), "all seven panes should retain usable bounds");
const auto stacked = Prism::Tui::buildDashboardLayout(
60, 20, Prism::Tui::LayoutPreset::Automatic);
require(stacked.resolvedPreset == Prism::Tui::LayoutPreset::Stacked,
"short terminals should stack their panels");
require(stacked.panels.size() == 3 &&
stacked.panels[0].panel == Prism::Tui::PanelId::Spectrum &&
stacked.panels[1].panel == Prism::Tui::PanelId::VUMeter &&
stacked.panels[2].panel == Prism::Tui::PanelId::LUFSMeter &&
stacked.panels[0].height + stacked.panels[1].height == 18 &&
stacked.panels[1].height == stacked.panels[2].height &&
stacked.panels[1].width + stacked.panels[2].width == 60,
"compact dashboards should keep VU and LUFS as separate scopes");
const auto spectrumRight = Prism::Tui::spatialNeighbor(
full,
Prism::Tui::PanelId::Spectrum,
Prism::Tui::NavigationDirection::Right);
require(spectrumRight &&
*spectrumRight == Prism::Tui::PanelId::Oscilloscope,
"spatial navigation should select the adjacent scope in a row");
const auto vectorscopeLeft = Prism::Tui::spatialNeighbor(
full,
Prism::Tui::PanelId::Vectorscope,
Prism::Tui::NavigationDirection::Left);
require(vectorscopeLeft &&
*vectorscopeLeft == Prism::Tui::PanelId::Oscilloscope,
"spatial navigation should move left within a row");
const auto spectrumDown = Prism::Tui::spatialNeighbor(
full,
Prism::Tui::PanelId::Spectrum,
Prism::Tui::NavigationDirection::Down);
require(spectrumDown && *spectrumDown == Prism::Tui::PanelId::Waveform,
"vertical navigation should choose the most-overlapping scope below");
const auto vectorscopeDown = Prism::Tui::spatialNeighbor(
full,
Prism::Tui::PanelId::Vectorscope,
Prism::Tui::NavigationDirection::Down);
require(vectorscopeDown &&
*vectorscopeDown == Prism::Tui::PanelId::LUFSMeter,
"vertical navigation should respect unequal scope widths");
const auto lufsUp = Prism::Tui::spatialNeighbor(
full,
Prism::Tui::PanelId::LUFSMeter,
Prism::Tui::NavigationDirection::Up);
require(lufsUp && *lufsUp == Prism::Tui::PanelId::Vectorscope,
"vertical spatial navigation should be reversible across rows");
require(!Prism::Tui::spatialNeighbor(
full,
Prism::Tui::PanelId::Spectrum,
Prism::Tui::NavigationDirection::Left),
"spatial navigation should stop at the dashboard edge");
const auto minimum = Prism::Tui::buildDashboardLayout(
44, 12, Prism::Tui::LayoutPreset::Automatic);
require(!minimum.terminalTooSmall && minimum.panels.size() == 2 &&
minimum.panels[0].height == 5 && minimum.panels[1].height == 5,
"minimum terminal layout should keep both panels usable");
require(Prism::Tui::buildDashboardLayout(
43, 12, Prism::Tui::LayoutPreset::Automatic).terminalTooSmall,
const auto twoRows = Prism::Tui::buildDashboardLayout(100, 20, rack);
require(twoRows.visibleRows == 2 && twoRows.hiddenRows == 1 &&
twoRows.panels.size() == 6 && twoRows.hiddenPanels == 1 &&
!Prism::Tui::layoutContainsPanel(
twoRows, Prism::Tui::PanelId::Spectrogram),
"shorter terminals should remove complete bottom rows instead of squashing them");
const auto oneRow = Prism::Tui::buildDashboardLayout(100, 14, rack);
require(oneRow.visibleRows == 1 && oneRow.hiddenRows == 2 &&
oneRow.panels.size() == 3 && oneRow.hiddenPanels == 4,
"height collapse should retain the first rack row at its usable size");
const auto narrow = Prism::Tui::buildDashboardLayout(60, 44, rack);
require(narrow.visibleRows == 3 && narrow.panels.size() == 5 &&
narrow.hiddenPanels == 2 &&
std::all_of(narrow.panels.begin(), narrow.panels.end(), [](const auto& panel) {
return panel.width >= 30;
}),
"narrow terminals should hide trailing scopes rather than crush their widths");
const auto minimum = Prism::Tui::buildDashboardLayout(44, 12, rack);
require(!minimum.terminalTooSmall && minimum.panels.size() == 1 &&
minimum.panels[0].panel == Prism::Tui::PanelId::Spectrum &&
minimum.panels[0].width == 44 && minimum.panels[0].height == 10,
"the minimum terminal should retain one complete usable scope");
require(Prism::Tui::buildDashboardLayout(43, 12, rack).terminalTooSmall,
"narrow resize should select the compact screen");
require(Prism::Tui::buildDashboardLayout(
80, 11, Prism::Tui::LayoutPreset::Automatic).terminalTooSmall,
require(Prism::Tui::buildDashboardLayout(80, 11, rack).terminalTooSmall,
"short resize should select the compact screen");
Prism::Tui::RackLayout compactMeters{{
{1, {{Prism::Tui::PanelId::LUFSMeter, 1}}},
{1, {{Prism::Tui::PanelId::VUMeter, 1}}},
}};
const auto compactMeterLayout = Prism::Tui::buildDashboardLayout(
44, 12, compactMeters);
require(compactMeterLayout.visibleRows == 2 &&
compactMeterLayout.panels.size() == 2,
"LUFS should fit in a compact four-row pane without hiding the next row");
const auto expanded = Prism::Tui::buildDashboardLayout(
100, 30, Prism::Tui::LayoutPreset::Columns, Prism::Tui::PanelId::LUFSMeter);
100, 30, rack, Prism::Tui::PanelId::LUFSMeter);
require(expanded.panels.size() == 1 &&
expanded.panels[0].panel == Prism::Tui::PanelId::LUFSMeter &&
expanded.panels[0].width == 100 && expanded.panels[0].height == 28,
@@ -212,21 +261,55 @@ void testProjectionAndLayout() {
require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) ==
Prism::Tui::PanelId::Waveform,
"panel focus should cycle backward");
const auto compactPanels = Prism::Tui::visiblePanelOrder(stacked);
const auto compactPanels = Prism::Tui::visiblePanelOrder(oneRow);
require(compactPanels.size() == 3 &&
Prism::Tui::nextPanel(
Prism::Tui::PanelId::Spectrum, compactPanels) == Prism::Tui::PanelId::VUMeter,
"compact layout focus should skip hidden visual scopes");
Prism::Tui::PanelId::Spectrum, compactPanels) ==
Prism::Tui::PanelId::Oscilloscope,
"compact layout focus should follow only the visible rack scopes");
const auto full = Prism::Tui::buildDashboardLayout(
140, 44, Prism::Tui::LayoutPreset::Automatic);
require(full.panels.size() == 7 &&
Prism::Tui::layoutContainsPanel(full, Prism::Tui::PanelId::Spectrogram) &&
Prism::Tui::layoutContainsPanel(full, Prism::Tui::PanelId::Waveform),
"large dashboards should compose all seven scopes at once");
require(std::all_of(full.panels.begin(), full.panels.end(), [](const auto& panel) {
return panel.width > 0 && panel.height > 0;
}), "all seven panes should remain bounded after responsive layout");
const std::string encodedRack = Prism::Tui::serializeRackLayout(rack);
require(Prism::Tui::parseRackLayout(encodedRack, {}) == rack,
"rack layouts should round-trip through their compact configuration form");
require(Prism::Tui::parseRackLayout("not-a-rack", rack) == rack,
"malformed saved racks should fall back safely");
auto editedRack = rack;
require(Prism::Tui::moveRackPanelHorizontal(
editedRack, Prism::Tui::PanelId::Spectrum, 1) &&
editedRack.rows[0].tiles[1].panel == Prism::Tui::PanelId::Spectrum,
"rack tiles should reorder within a row");
require(Prism::Tui::moveRackPanelVertical(
editedRack, Prism::Tui::PanelId::Spectrum, 1) &&
Prism::Tui::rackPanelLocation(
editedRack, Prism::Tui::PanelId::Spectrum)->first == 1,
"rack tiles should move between rows");
require(Prism::Tui::resizeRackPanel(
editedRack, Prism::Tui::PanelId::Spectrum, 1) &&
Prism::Tui::resizeRackRow(
editedRack, Prism::Tui::PanelId::Spectrum, 1),
"rack editing should resize both scope widths and row heights");
Prism::Tui::RackLayout splitRack{{
{1, {{Prism::Tui::PanelId::Spectrum, 1},
{Prism::Tui::PanelId::Oscilloscope, 1}}},
}};
require(Prism::Tui::splitRackRow(
splitRack, Prism::Tui::PanelId::Spectrum) &&
splitRack.rows.size() == 2,
"rack editing should split a scope into a new row");
require(Prism::Tui::removeRackPanel(
editedRack, Prism::Tui::PanelId::Vectorscope) &&
Prism::Tui::addRackPanel(
editedRack,
Prism::Tui::PanelId::Vectorscope,
Prism::Tui::PanelId::Oscilloscope),
"rack editing should remove and restore optional scopes");
Prism::Tui::RackLayout lastScope{{
{1, {{Prism::Tui::PanelId::Spectrum, 1}}},
}};
require(!Prism::Tui::removeRackPanel(
lastScope, Prism::Tui::PanelId::Spectrum),
"rack editing should never remove the final scope");
}
void testMeterDisplayModels() {
@@ -291,7 +374,7 @@ void testSettingsModelAndPersistence() {
const auto pages = Prism::Tui::settingsPages();
require(pages.size() == 8 &&
Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 3,
Prism::Tui::settingsForPage(Prism::Tui::SettingsPage::General).size() == 2,
"settings should expose shallow category pages");
Prism::Tui::TuiSettings adjusted;
require(Prism::Tui::adjustSetting(
@@ -344,7 +427,9 @@ void testSettingsModelAndPersistence() {
"prism-tui-settings-test.conf";
std::error_code ignored;
std::filesystem::remove(settingsPath, ignored);
adjusted.layoutPreset = Prism::Tui::LayoutPreset::Columns;
require(Prism::Tui::moveRackPanelHorizontal(
adjusted.rackLayout, Prism::Tui::PanelId::Spectrum, 1),
"settings persistence should include an edited rack layout");
adjusted.vectorscopeMode = Prism::Tui::VectorscopeMode::PolarBipolar;
adjusted.vectorscopeDetail = Prism::Tui::VectorscopeDetail::Maximum;
std::string error;
@@ -355,6 +440,89 @@ void testSettingsModelAndPersistence() {
std::filesystem::remove(settingsPath, ignored);
}
void testProfileLibrary() {
const auto root = std::filesystem::temp_directory_path() /
"prism-tui-profile-library-test";
const auto profilesPath = root / "profiles";
const auto statePath = root / "profile-state.conf";
std::error_code ignored;
std::filesystem::remove_all(root, ignored);
Prism::Tui::TuiProfileLibrary library(profilesPath, statePath);
std::string error;
require(library.load(&error),
"the profile library should initialize its managed directory");
require(library.profiles().size() == 1 &&
library.profiles().front().id == Prism::Tui::kDefaultTuiProfileId &&
library.profiles().front().isDefault,
"the profile library should always provide a default profile");
Prism::Tui::TuiSettings settings;
settings.inputTrimDb = 4.0f;
settings.refreshRate = 30;
settings.spectrogramContrast = 1.7f;
require(Prism::Tui::moveRackPanelHorizontal(
settings.rackLayout, Prism::Tui::PanelId::Spectrum, 1),
"profile fixtures should retain custom rack ordering");
std::string profileId;
require(library.saveNew("Studio Wide", settings, &profileId, &error),
"the current TUI setup should save as a named profile");
require(library.activeProfileId() == profileId &&
library.find(profileId) &&
Prism::Tui::profileSettingsEqual(
library.find(profileId)->settings, settings),
"saving a profile should activate and preserve its scoped settings");
bool foundProfileFile = false;
for (const auto& entry : std::filesystem::directory_iterator(profilesPath)) {
if (entry.path().extension() != Prism::Tui::kTuiProfileExtension) continue;
std::ifstream input(entry.path());
const std::string text{
std::istreambuf_iterator<char>(input),
std::istreambuf_iterator<char>()};
if (text.find("id=" + profileId) == std::string::npos) continue;
foundProfileFile = true;
require(text.find("format=prism-tui-profile") != std::string::npos &&
text.find("refresh_rate=") == std::string::npos,
".prsmt files should be versioned and exclude global refresh settings");
}
require(foundProfileFile,
"saved TUI profiles should use the .prsmt extension");
Prism::Tui::TuiSettings differentRefresh = settings;
differentRefresh.refreshRate = 60;
require(Prism::Tui::profileSettingsEqual(settings, differentRefresh),
"refresh rate should not make an active profile dirty");
const auto applied = Prism::Tui::applyProfileSettings(
settings, differentRefresh);
require(applied.refreshRate == 60 && applied.inputTrimDb == 4.0f,
"loading a profile should preserve global refresh rate while applying trim");
settings.waveformMultiband = true;
require(library.overwrite(profileId, settings, &error) &&
library.find(profileId)->settings.waveformMultiband,
"overwriting should replace the active profile snapshot");
require(library.renameProfile(profileId, "Live Rack", &error) &&
library.find(profileId)->name == "Live Rack",
"user profiles should be renameable without changing their identity");
Prism::Tui::TuiProfileLibrary reloaded(profilesPath, statePath);
require(reloaded.load(&error) &&
reloaded.activeProfileId() == profileId &&
reloaded.find(profileId)->name == "Live Rack",
"the active profile and renamed file should survive a restart");
require(!reloaded.renameProfile(
Prism::Tui::kDefaultTuiProfileId, "Other", &error) &&
!reloaded.deleteProfile(
Prism::Tui::kDefaultTuiProfileId, &error),
"the default profile should not be renamed or deleted");
require(reloaded.deleteProfile(profileId, &error) &&
reloaded.activeProfileId().empty() && !reloaded.find(profileId),
"deleting the active profile should clear the active selection safely");
std::filesystem::remove_all(root, ignored);
}
void testScopePlotModels() {
const auto oscilloscope = Prism::Tui::buildOscilloscopePlot(
{-1.0f, 0.0f, 1.0f}, 9, 9);
@@ -679,6 +847,7 @@ int main() {
testMeterDisplayModels();
testSpectrumPeakModel();
testSettingsModelAndPersistence();
testProfileLibrary();
testScopePlotModels();
testPitchReadoutResponse();
testScrollingHistory();