mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-18 03:33:40 +02:00
profile and customization system for tui
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+43
-47
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user