diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index 4eb2edd..7ced950 100644 --- a/tui/CMakeLists.txt +++ b/tui/CMakeLists.txt @@ -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 diff --git a/tui/src/cli.cpp b/tui/src/cli.cpp index d5b8bbd..6b78875 100644 --- a/tui/src/cli.cpp +++ b/tui/src/cli.cpp @@ -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"; } diff --git a/tui/src/dashboard_layout.cpp b/tui/src/dashboard_layout.cpp index 7706cf2..f5a61ce 100644 --- a/tui/src/dashboard_layout.cpp +++ b/tui/src/dashboard_layout.cpp @@ -1,7 +1,9 @@ #include "dashboard_layout.h" #include +#include #include +#include #include 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& 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 visibleRackTiles(const RackRow& row, int width) { + std::vector 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 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 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 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 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 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> 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 configuredPanelOrder(const RackLayout& rack) { + std::vector 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(location->second) + + (direction > 0 ? 1 : -1); + if (destination < 0 || destination >= static_cast(tiles.size())) { + return false; + } + std::swap(tiles[location->second], tiles[static_cast(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(location->first) + + (direction > 0 ? 1 : -1); + if (targetRow < 0 || targetRow >= static_cast(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(location->second)); + size_t resolvedTarget = static_cast(targetRow); + if (rack.rows[location->first].tiles.empty()) { + rack.rows.erase(rack.rows.begin() + + static_cast(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(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(location->second)); + rack.rows.insert( + rack.rows.begin() + static_cast(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(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(target->second + 1), + RackTile{panel, 1}); + rack = normalizeRackLayout(std::move(rack)); + return rack != before; } std::vector panelOrder() { @@ -300,10 +550,8 @@ PanelId nextPanel(PanelId panel, std::vector visiblePanelOrder(const DashboardLayout& layout) { std::vector 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 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{best->panel}; + } + + std::optional 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{best->panel}; +} + } // namespace Prism::Tui diff --git a/tui/src/dashboard_layout.h b/tui/src/dashboard_layout.h index 77dfc59..92173d9 100644 --- a/tui/src/dashboard_layout.h +++ b/tui/src/dashboard_layout.h @@ -2,6 +2,7 @@ #include #include +#include #include 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 tiles; +}; + +struct RackLayout { + std::vector 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 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 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> rackPanelLocation( + const RackLayout& rack, + PanelId panel); +std::vector 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 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 visiblePanelOrder(const DashboardLayout& layout); bool layoutContainsPanel(const DashboardLayout& layout, PanelId panel); +std::optional spatialNeighbor(const DashboardLayout& layout, + PanelId panel, + NavigationDirection direction); } // namespace Prism::Tui diff --git a/tui/src/profile_library.cpp b/tui/src/profile_library.cpp new file mode 100644 index 0000000..76a3fca --- /dev/null +++ b/tui/src/profile_library.cpp @@ -0,0 +1,443 @@ +#include "profile_library.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(input), + std::istreambuf_iterator()); +} + +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(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 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(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 diff --git a/tui/src/profile_library.h b/tui/src/profile_library.h new file mode 100644 index 0000000..1b664ef --- /dev/null +++ b/tui/src/profile_library.h @@ -0,0 +1,76 @@ +#pragma once + +#include "tui_settings.h" + +#include +#include +#include + +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& 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 managed_; + std::vector 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 diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index ec8e839..c150497 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -4,6 +4,7 @@ #include "dashboard_layout.h" #include "display_model.h" #include "meter_display_model.h" +#include "profile_library.h" #include "scope_plot_model.h" #include "snapshot_store.h" #include "tui_settings.h" @@ -127,17 +128,65 @@ struct DisplayFrame { bool captureOverrun = false; }; +enum class LayoutOverlay { + None, + AddScope, + Help, +}; + +enum class ProfileOverlayMode { + Browse, + SaveAs, + Rename, + ConfirmOverwrite, + ConfirmDelete, + ConfirmLoad, +}; + struct InterfaceState { PanelId focusedPanel = PanelId::Spectrum; std::optional expandedPanel; TuiSettings settings; + bool layoutEditing = false; + LayoutOverlay layoutOverlay = LayoutOverlay::None; + size_t layoutAddSelection = 0; + std::string layoutStatus; bool settingsOpen = false; SettingsPage settingsPage = SettingsPage::Home; size_t settingsHomeSelection = 0; std::array settingsSelections{}; std::string settingsStatus; + bool profilesOpen = false; + ProfileOverlayMode profileMode = ProfileOverlayMode::Browse; + std::vector profiles; + std::string activeProfileId; + bool profileDirty = false; + size_t profileSelection = 0; + std::string profileInput; + std::string profileStatus; + bool profileStatusError = false; + std::string pendingProfileId; }; +const TuiProfile* activeProfile(const InterfaceState& state) { + const auto found = std::find_if( + state.profiles.begin(), state.profiles.end(), [&](const auto& profile) { + return profile.id == state.activeProfileId; + }); + return found == state.profiles.end() ? nullptr : &*found; +} + +bool calculateUnsavedProfileChanges(const InterfaceState& state) { + if (const auto* active = activeProfile(state)) { + return !profileSettingsEqual(state.settings, active->settings); + } + return !profileSettingsEqual(state.settings, TuiSettings{}); +} + +bool hasUnsavedProfileChanges(const InterfaceState& state) { + return state.profileDirty; +} + size_t settingsPageIndex(SettingsPage page) { return static_cast(page); } @@ -229,6 +278,77 @@ std::string panelNumber(PanelId panel) { return "?"; } +std::vector removedRackPanels(const RackLayout& rack) { + std::vector removed; + for (const auto panel : panelOrder()) { + if (!rackPanelLocation(rack, panel)) removed.push_back(panel); + } + return removed; +} + +std::optional plainArrowDirection( + const ftxui::Event& event) { + using ftxui::Event; + if (event == Event::ArrowLeft) return NavigationDirection::Left; + if (event == Event::ArrowRight) return NavigationDirection::Right; + if (event == Event::ArrowUp) return NavigationDirection::Up; + if (event == Event::ArrowDown) return NavigationDirection::Down; + return std::nullopt; +} + +std::optional moveArrowDirection( + const ftxui::Event& event) { + using ftxui::Event; + if (event == Event::ArrowLeftCtrl || + event == Event::Special("\x1b[1;2D") || + event == Event::Special("\x1b[d") || + event == Event::Character('H')) { + return NavigationDirection::Left; + } + if (event == Event::ArrowRightCtrl || + event == Event::Special("\x1b[1;2C") || + event == Event::Special("\x1b[c") || + event == Event::Character('L')) { + return NavigationDirection::Right; + } + if (event == Event::ArrowUpCtrl || + event == Event::Special("\x1b[1;2A") || + event == Event::Special("\x1b[a") || + event == Event::Character('K')) { + return NavigationDirection::Up; + } + if (event == Event::ArrowDownCtrl || + event == Event::Special("\x1b[1;2B") || + event == Event::Special("\x1b[b") || + event == Event::Character('J')) { + return NavigationDirection::Down; + } + return std::nullopt; +} + +void eraseLastUtf8Character(std::string& value) { + if (value.empty()) return; + value.pop_back(); + while (!value.empty() && + (static_cast(value.back()) & 0xc0) == 0x80) { + value.pop_back(); + } +} + +bool appendProfileNameCharacter(std::string& value, + const ftxui::Event& event) { + if (!event.is_character()) return false; + const std::string character = event.character(); + if (character.empty() || value.size() + character.size() > 64) return true; + if (std::any_of(character.begin(), character.end(), [](unsigned char byte) { + return byte < 0x20 || byte == 0x7f; + })) { + return true; + } + value += character; + return true; +} + ftxui::Element panelTitle(PanelId panel, bool focused, const std::string& detail = {}) { @@ -1381,19 +1501,56 @@ ftxui::Element renderLayoutNode(const LayoutNode& node, } ftxui::Element renderHeader(const DashboardLayout& layout, - const InterfaceState& state) { + const InterfaceState& state, + int width) { using namespace ftxui; - std::string layoutName = layoutPresetName(state.settings.layoutPreset); - if (state.settings.layoutPreset == LayoutPreset::Automatic) { - layoutName += "→" + layoutPresetName(layout.resolvedPreset); + std::ostringstream rackStatus; + if (state.layoutEditing) { + rackStatus << "EDIT LAYOUT"; + if (const auto location = rackPanelLocation( + state.settings.rackLayout, state.focusedPanel)) { + const auto& row = state.settings.rackLayout.rows[location->first]; + const auto& tile = row.tiles[location->second]; + rackStatus << " • row " << location->first + 1 << "/" + << state.settings.rackLayout.rows.size(); + if (width >= 76) { + rackStatus << " • width " << tile.weight + << " • height " << row.weight; + } + } + const size_t removedCount = removedRackPanels( + state.settings.rackLayout).size(); + if (removedCount > 0 && width >= 110) { + rackStatus << " • " << removedCount << " removed"; + } + } else { + rackStatus << "rack • " << layout.visibleRows << "/" + << layout.configuredRows << " rows"; + if (layout.hiddenPanels > 0 && width >= 80) { + rackStatus << " • " << layout.hiddenPanels << " scope" + << (layout.hiddenPanels == 1 ? "" : "s") << " hidden"; + } } + const auto* profile = activeProfile(state); + const bool profileDirty = hasUnsavedProfileChanges(state); + const std::string profileLabel = profile + ? " • " + profile->name + (profileDirty ? " *" : "") + : profileDirty ? " • Working *" : ""; + Element profileElement = width >= 90 && !profileLabel.empty() + ? text(profileLabel) | + (profileDirty ? color(Color::YellowLight) : dim) | + size(WIDTH, LESS_THAN, 32) + : emptyElement(); return hbox({ text(" PRISM") | color(Color::CyanLight) | bold, text(" TUI") | bold, + std::move(profileElement), filler(), state.expandedPanel ? text("FOCUS • " + panelName(*state.expandedPanel) + " ") | color(Color::CyanLight) - : text(layoutName + " ") | dim, + : state.layoutEditing + ? text(rackStatus.str() + " ") | color(Color::CyanLight) | bold + : text(rackStatus.str() + " ") | dim, }); } @@ -1403,13 +1560,36 @@ ftxui::Element renderFooter(const DisplayFrame& frame, using namespace ftxui; const bool compact = width < 108; const bool minimal = width < 64; + if (state.layoutEditing) { + const std::string essentialControls = "a add • ? help • Enter done"; + if (!state.layoutStatus.empty()) { + if (width < 80) { + return text(" " + state.layoutStatus) | + color(Color::CyanLight); + } + return hbox({ + text(" " + state.layoutStatus) | color(Color::CyanLight) | bold, + filler(), + text(essentialControls + " ") | color(Color::GrayLight), + }); + } + const std::string controls = width < 64 + ? essentialControls + : width < 110 + ? "arrows select • Shift+arrows move • a add • ? help • Enter done" + : width < 160 + ? "arrows select • Shift+arrows move • [ ] width • a add • x remove • ? help • Enter done" + : "arrows select • Shift+arrows move • [] width • ,. height • n new row • a add • x remove • ? help • Enter done"; + return text(controls + " ") | color(Color::CyanLight) | align_right; + } const std::string enterAction = state.expandedPanel ? "restore" : "expand"; const std::string controls = minimal - ? "Tab • Enter • s • q" + ? "Tab • Enter • p • s • q" : compact - ? "Tab focus • Enter " + enterAction + " • s settings • q quit" + ? "Tab focus • Enter " + enterAction + + " • p profiles • s settings • q quit" : "Tab focus • Enter " + enterAction + - " • s settings • v mode • l layout • r reset • q quit"; + " • p profiles • s settings • l edit layout • r reset • q quit"; auto status = text(makeCaptureStatus(frame, state.settings, compact)) | dim; if (frame.captureOverrun) { status = status | color(Color::RedLight); @@ -1441,6 +1621,104 @@ ftxui::Element settingsRow(const std::string& label, return row | size(HEIGHT, EQUAL, 1); } +ftxui::Element renderLayoutAddScope(const InterfaceState& state, + int width, + int height) { + using namespace ftxui; + const int contentWidth = std::max(1, width - 2); + const int contentHeight = std::max(1, height - 2); + const auto removed = removedRackPanels(state.settings.rackLayout); + Elements rows; + if (removed.empty()) { + rows.push_back(filler()); + rows.push_back( + text("All seven scopes are already in the rack.") | + color(Color::GrayLight) | center); + rows.push_back(filler()); + } else { + const size_t selected = std::min( + state.layoutAddSelection, removed.size() - 1); + const size_t maximumVisible = static_cast( + std::max(1, contentHeight - 8)); + const size_t firstVisible = selected >= maximumVisible + ? selected - maximumVisible + 1 + : 0; + const size_t lastVisible = std::min( + removed.size(), firstVisible + maximumVisible); + for (size_t index = firstVisible; index < lastVisible; ++index) { + rows.push_back(settingsRow( + panelNumber(removed[index]) + " " + panelName(removed[index]), + index == selected ? "add" : "", + index == selected)); + } + rows.push_back(filler()); + } + + std::string destination = "after " + panelName(state.focusedPanel); + if (const auto location = rackPanelLocation( + state.settings.rackLayout, state.focusedPanel)) { + destination += " in row " + std::to_string(location->first + 1); + } + auto content = vbox({ + text(" PRISM / EDIT LAYOUT / ADD SCOPE") | + color(Color::CyanLight) | bold, + separator(), + removed.empty() + ? text("Nothing to restore.") | dim + : text("Choose a removed scope. It will be inserted " + destination + ".") | dim, + separatorEmpty(), + vbox(std::move(rows)), + separator(), + text(removed.empty() + ? "Esc back" + : "↑↓ select • Enter add • Esc back") | dim, + }) | size(WIDTH, EQUAL, contentWidth) | + size(HEIGHT, EQUAL, contentHeight); + return std::move(content) | borderRounded | + size(WIDTH, EQUAL, width) | + size(HEIGHT, EQUAL, height); +} + +ftxui::Element renderLayoutHelp(int width, int height) { + using namespace ftxui; + const int contentWidth = std::max(1, width - 2); + const int contentHeight = std::max(1, height - 2); + Elements instructions; + if (contentHeight < 15) { + instructions.push_back(text("Arrows select • Shift+arrows move.")); + instructions.push_back(text("Ctrl+arrows or H/J/K/L also move.")); + instructions.push_back(text("[ ] scope width • , . row height.")); + instructions.push_back(text("a adds by name • x removes.")); + } else { + instructions.push_back(text("Arrow keys Select the nearest scope spatially.")); + instructions.push_back(text("Tab / Shift-Tab Select the next or previous visible scope.")); + instructions.push_back(text("Shift + arrows Reorder within a row or move between rows.")); + instructions.push_back(text("Ctrl + arrows Movement fallback for terminal compatibility.")); + instructions.push_back(text("H / J / K / L Additional movement fallback (uppercase).")); + instructions.push_back(text("[ / ] Make the selected scope narrower or wider.")); + instructions.push_back(text(", / . Make the selected row shorter or taller.")); + instructions.push_back(text("n Move the scope into a new row (three maximum).")); + instructions.push_back(text("x Remove the selected scope.")); + instructions.push_back(text("a Add a removed scope by name.")); + } + + auto content = vbox({ + text(" PRISM / EDIT LAYOUT / HELP") | color(Color::CyanLight) | bold, + separator(), + vbox(std::move(instructions)), + filler(), + contentHeight >= 12 + ? text("Changes are saved immediately.") | color(Color::GrayLight) + : emptyElement(), + separator(), + text("? / Esc back") | dim, + }) | size(WIDTH, EQUAL, contentWidth) | + size(HEIGHT, EQUAL, contentHeight); + return std::move(content) | borderRounded | + size(WIDTH, EQUAL, width) | + size(HEIGHT, EQUAL, height); +} + ftxui::Element renderSettings(const InterfaceState& state, int width, int height) { @@ -1517,13 +1795,177 @@ ftxui::Element renderSettings(const InterfaceState& state, size(HEIGHT, EQUAL, height); } +ftxui::Element renderProfiles(const InterfaceState& state, + int width, + int height) { + using namespace ftxui; + const int contentWidth = std::max(1, width - 2); + const int contentHeight = std::max(1, height - 2); + const auto selectedIndex = state.profiles.empty() + ? size_t{0} + : std::min(state.profileSelection, state.profiles.size() - 1); + const TuiProfile* selected = state.profiles.empty() + ? nullptr + : &state.profiles[selectedIndex]; + const TuiProfile* active = activeProfile(state); + const bool dirty = hasUnsavedProfileChanges(state); + + if (state.profileMode == ProfileOverlayMode::Browse) { + Elements rows; + const size_t maximumVisibleRows = static_cast( + std::max(1, contentHeight - 8)); + const size_t firstVisible = selectedIndex >= maximumVisibleRows + ? selectedIndex - maximumVisibleRows + 1 + : 0; + const size_t lastVisible = std::min( + state.profiles.size(), firstVisible + maximumVisibleRows); + for (size_t index = firstVisible; index < lastVisible; ++index) { + const auto& profile = state.profiles[index]; + const bool isSelected = index == selectedIndex; + const bool isActive = profile.id == state.activeProfileId; + auto row = hbox({ + text(isSelected ? " › " : " "), + text(isActive ? "● " : " ") | + color(isActive ? Color::CyanLight : Color::GrayDark), + text(profile.name) | (isSelected ? bold : dim), + isActive && dirty + ? text(" * modified") | color(Color::YellowLight) + : emptyElement(), + filler(), + profile.isDefault ? text("default ") | dim : emptyElement(), + }); + rows.push_back(isSelected + ? std::move(row) | bgcolor(Color::RGB(18, 49, 52)) | + color(Color::CyanLight) + : std::move(row)); + } + const std::string activeDescription = active + ? "Active: " + active->name + (dirty ? " • modified" : "") + : dirty + ? "Working setup is not saved to a profile." + : "No active profile."; + auto content = vbox({ + text(" PRISM / PROFILES") | color(Color::CyanLight) | bold, + separator(), + text(activeDescription) | + (dirty ? color(Color::YellowLight) : color(Color::GrayLight)), + separatorEmpty(), + vbox(std::move(rows)), + filler(), + state.profileStatus.empty() + ? emptyElement() + : text(state.profileStatus) | color( + state.profileStatusError + ? Color::RedLight + : Color::CyanLight), + separator(), + text(contentWidth < 60 + ? "↑↓ select • Enter load • n save • Esc" + : contentWidth < 74 + ? "↑↓ select • Enter load • n new • w write • Esc" + : "↑↓ select • Enter load • n save as • w overwrite • r rename • d delete • Esc") | dim, + }) | size(WIDTH, EQUAL, contentWidth) | + size(HEIGHT, EQUAL, contentHeight); + return std::move(content) | borderRounded | + size(WIDTH, EQUAL, width) | + size(HEIGHT, EQUAL, height); + } + + std::string title; + std::string message; + std::string controls = "Enter confirm • Esc cancel"; + Element action = emptyElement(); + switch (state.profileMode) { + case ProfileOverlayMode::SaveAs: + title = " PRISM / PROFILES / SAVE AS"; + message = state.pendingProfileId.empty() + ? "Save the current rack and scope settings as a new profile." + : "Save the current setup before loading another profile."; + action = hbox({ + text(" Name ") | dim, + text(state.profileInput.empty() ? " " : state.profileInput) | bold, + text("▌") | color(Color::CyanLight), + }) | border; + break; + case ProfileOverlayMode::Rename: + title = " PRISM / PROFILES / RENAME"; + message = selected + ? "Rename “" + selected->name + "”." + : "Choose a profile to rename."; + action = hbox({ + text(" Name ") | dim, + text(state.profileInput.empty() ? " " : state.profileInput) | bold, + text("▌") | color(Color::CyanLight), + }) | border; + break; + case ProfileOverlayMode::ConfirmOverwrite: + title = " PRISM / PROFILES / OVERWRITE"; + message = active + ? "Replace “" + active->name + "” with the current setup?" + : "There is no active profile to overwrite."; + action = text("This changes the saved .prsmt file.") | + color(Color::YellowLight); + break; + case ProfileOverlayMode::ConfirmDelete: + title = " PRISM / PROFILES / DELETE"; + message = selected + ? "Delete “" + selected->name + "”?" + : "Choose a profile to delete."; + action = text("This cannot be undone.") | color(Color::RedLight); + controls = "d confirm delete • Esc cancel"; + break; + case ProfileOverlayMode::ConfirmLoad: + title = " PRISM / PROFILES / UNSAVED CHANGES"; + message = "Loading another profile will replace the current setup."; + action = contentWidth < 60 + ? active + ? vbox({ + text("w save active profile, then load"), + text("n save as a new profile, then load"), + text("d discard changes and load"), + }) | color(Color::YellowLight) + : vbox({ + text("n save as a new profile, then load"), + text("d discard changes and load"), + }) | color(Color::YellowLight) + : text(active + ? "w save & load • n save as & load • d discard & load" + : "n save as & load • d discard & load") | + color(Color::YellowLight); + controls = "Choose an action • Esc cancel"; + break; + case ProfileOverlayMode::Browse: + break; + } + auto content = vbox({ + text(title) | color(Color::CyanLight) | bold, + separator(), + text(message), + separatorEmpty(), + std::move(action), + filler(), + state.profileStatus.empty() + ? emptyElement() + : text(state.profileStatus) | color( + state.profileStatusError + ? Color::RedLight + : Color::CyanLight), + separator(), + text(controls) | dim, + }) | size(WIDTH, EQUAL, contentWidth) | + size(HEIGHT, EQUAL, contentHeight); + return std::move(content) | borderRounded | + size(WIDTH, EQUAL, width) | + size(HEIGHT, EQUAL, height); +} + ftxui::Element renderFrame(const DisplayFrame& frame, int width, int height, const InterfaceState& state) { using namespace ftxui; const auto layout = buildDashboardLayout( - width, height, state.settings.layoutPreset, state.expandedPanel); + width, height, state.settings.rackLayout, state.expandedPanel); if (layout.terminalTooSmall) { return vbox({ filler(), @@ -1535,21 +1977,43 @@ ftxui::Element renderFrame(const DisplayFrame& frame, } auto dashboard = vbox({ - renderHeader(layout, state) | size(HEIGHT, EQUAL, 1), + renderHeader(layout, state, width) | size(HEIGHT, EQUAL, 1), renderLayoutNode(layout.root, layout, frame, state), renderFooter(frame, state, width) | size(HEIGHT, EQUAL, 1), }); - if (!state.settingsOpen) { - return dashboard; + if (state.profilesOpen) { + const int profilesWidth = std::min(92, std::max(44, width - 4)); + const int profilesHeight = std::min(18, std::max(12, height - 2)); + return dbox({ + std::move(dashboard) | dim, + renderProfiles(state, profilesWidth, profilesHeight) | + borderEmpty | clear_under | center, + }); } - - const int settingsWidth = std::min(84, std::max(40, width - 4)); - const int settingsHeight = std::min(16, std::max(10, height - 2)); - return dbox({ - std::move(dashboard) | dim, - renderSettings(state, settingsWidth, settingsHeight) | - borderEmpty | clear_under | center, - }); + if (state.settingsOpen) { + const int settingsWidth = std::min(84, std::max(40, width - 4)); + const int settingsHeight = std::min(16, std::max(10, height - 2)); + return dbox({ + std::move(dashboard) | dim, + renderSettings(state, settingsWidth, settingsHeight) | + borderEmpty | clear_under | center, + }); + } + if (state.layoutEditing && state.layoutOverlay != LayoutOverlay::None) { + const bool help = state.layoutOverlay == LayoutOverlay::Help; + const int overlayWidth = std::min( + help ? 84 : 72, std::max(40, width - 4)); + const int overlayHeight = std::min( + help ? 18 : 16, std::max(10, height - 2)); + auto overlay = help + ? renderLayoutHelp(overlayWidth, overlayHeight) + : renderLayoutAddScope(state, overlayWidth, overlayHeight); + return dbox({ + std::move(dashboard) | dim, + std::move(overlay) | borderEmpty | clear_under | center, + }); + } + return dashboard; } } // namespace @@ -1574,6 +2038,18 @@ int runInteractive(std::unique_ptr capture, InterfaceState interfaceState; const std::filesystem::path settingsPath = defaultSettingsPath(); interfaceState.settings = loadSettings(settingsPath); + TuiProfileLibrary profileLibrary( + defaultProfileDirectory(), defaultProfileStatePath()); + std::string profileLoadError; + if (!profileLibrary.load(&profileLoadError)) { + interfaceState.profileStatus = + "Could not load profiles: " + profileLoadError; + interfaceState.profileStatusError = true; + } + interfaceState.profiles = profileLibrary.profiles(); + interfaceState.activeProfileId = profileLibrary.activeProfileId(); + interfaceState.profileDirty = + calculateUnsavedProfileChanges(interfaceState); settingsStore.publish(interfaceState.settings); DisplayFrame initial; initial.magnitudes.assign(kDefaultFftSize / 2, -100.0f); @@ -1681,6 +2157,8 @@ int runInteractive(std::unique_ptr capture, }); const auto persistSettings = [&]() { interfaceState.settings = normalizeSettings(interfaceState.settings); + interfaceState.profileDirty = + calculateUnsavedProfileChanges(interfaceState); settingsStore.publish(interfaceState.settings); std::string error; if (!saveSettings(interfaceState.settings, settingsPath, &error)) { @@ -1689,13 +2167,68 @@ int runInteractive(std::unique_ptr capture, interfaceState.settingsStatus.clear(); } }; + const auto syncProfiles = [&]() { + interfaceState.profiles = profileLibrary.profiles(); + interfaceState.activeProfileId = profileLibrary.activeProfileId(); + interfaceState.profileDirty = + calculateUnsavedProfileChanges(interfaceState); + if (interfaceState.profiles.empty()) { + interfaceState.profileSelection = 0; + } else { + interfaceState.profileSelection = std::min( + interfaceState.profileSelection, + interfaceState.profiles.size() - 1); + } + }; + const auto selectProfile = [&](const std::string& id) { + const auto found = std::find_if( + interfaceState.profiles.begin(), + interfaceState.profiles.end(), + [&](const auto& profile) { return profile.id == id; }); + if (found != interfaceState.profiles.end()) { + interfaceState.profileSelection = static_cast( + std::distance(interfaceState.profiles.begin(), found)); + } + }; + const auto loadProfile = [&](const std::string& id) { + const auto* profile = profileLibrary.find(id); + if (!profile) { + interfaceState.profileStatus = "Profile was not found."; + interfaceState.profileStatusError = true; + return false; + } + const TuiSettings loaded = applyProfileSettings( + profile->settings, interfaceState.settings); + std::string error; + if (!profileLibrary.activate(id, &error)) { + interfaceState.profileStatus = "Could not activate profile: " + error; + interfaceState.profileStatusError = true; + return false; + } + interfaceState.settings = loaded; + interfaceState.expandedPanel.reset(); + persistSettings(); + const auto dashboard = buildDashboardLayout( + screen.dimx(), + screen.dimy(), + interfaceState.settings.rackLayout); + if (!layoutContainsPanel(dashboard, interfaceState.focusedPanel)) { + const auto visible = visiblePanelOrder(dashboard); + if (!visible.empty()) interfaceState.focusedPanel = visible.front(); + } + syncProfiles(); + selectProfile(id); + interfaceState.profileStatus = "Loaded " + profile->name; + interfaceState.profileStatusError = false; + return true; + }; const auto closeSettings = [&]() { interfaceState.settingsOpen = false; interfaceState.settingsPage = SettingsPage::Home; const auto dashboard = buildDashboardLayout( screen.dimx(), screen.dimy(), - interfaceState.settings.layoutPreset, + interfaceState.settings.rackLayout, interfaceState.expandedPanel); if (!interfaceState.expandedPanel && !layoutContainsPanel(dashboard, interfaceState.focusedPanel)) { @@ -1703,17 +2236,352 @@ int runInteractive(std::unique_ptr capture, if (!visible.empty()) interfaceState.focusedPanel = visible.front(); } }; + const auto panelForEvent = [](const Event& event) -> std::optional { + if (event == Event::Character('1')) return PanelId::Spectrum; + if (event == Event::Character('2')) return PanelId::Oscilloscope; + if (event == Event::Character('3')) return PanelId::Vectorscope; + if (event == Event::Character('4')) return PanelId::VUMeter; + if (event == Event::Character('5')) return PanelId::LUFSMeter; + if (event == Event::Character('6')) return PanelId::Spectrogram; + if (event == Event::Character('7')) return PanelId::Waveform; + return std::nullopt; + }; + const auto addedScopeStatus = [&](PanelId panel) { + const auto dashboard = buildDashboardLayout( + screen.dimx(), screen.dimy(), interfaceState.settings.rackLayout); + return "Added " + panelName(panel) + + (layoutContainsPanel(dashboard, panel) + ? "" + : " • hidden at this terminal size"); + }; auto component = CatchEvent(renderer, [&](Event event) { if (event == Event::Custom) { redrawQueued.store(false); return false; } - if (event == Event::Character('q') || event == Event::CtrlC) { + if (event == Event::CtrlC) { running.store(false); exitLoop(); return true; } + if (interfaceState.profilesOpen) { + const auto selectedProfile = [&]() -> const TuiProfile* { + if (interfaceState.profiles.empty()) return nullptr; + return &interfaceState.profiles[std::min( + interfaceState.profileSelection, + interfaceState.profiles.size() - 1)]; + }; + const auto returnToProfileBrowser = [&]() { + interfaceState.profileMode = ProfileOverlayMode::Browse; + interfaceState.profileInput.clear(); + interfaceState.pendingProfileId.clear(); + }; + + if (interfaceState.profileMode == ProfileOverlayMode::Browse) { + if (event == Event::Escape || event == Event::Character('p')) { + interfaceState.profilesOpen = false; + interfaceState.profileStatus.clear(); + return true; + } + if (!interfaceState.profiles.empty() && + (event == Event::ArrowUp || event == Event::ArrowDown)) { + const int direction = event == Event::ArrowDown ? 1 : -1; + const int count = static_cast(interfaceState.profiles.size()); + interfaceState.profileSelection = static_cast( + (static_cast(interfaceState.profileSelection) + + direction + count) % count); + interfaceState.profileStatus.clear(); + return true; + } + if (event == Event::Character('n')) { + interfaceState.profileMode = ProfileOverlayMode::SaveAs; + interfaceState.profileInput.clear(); + interfaceState.profileStatus.clear(); + return true; + } + if (event == Event::Character('w')) { + if (interfaceState.activeProfileId.empty()) { + interfaceState.profileStatus = + "No active profile. Use n to save this setup first."; + interfaceState.profileStatusError = true; + } else { + interfaceState.profileMode = + ProfileOverlayMode::ConfirmOverwrite; + interfaceState.profileStatus.clear(); + } + return true; + } + if (event == Event::Character('r')) { + const auto* selected = selectedProfile(); + if (!selected) { + interfaceState.profileStatus = "No profile is selected."; + interfaceState.profileStatusError = true; + } else if (selected->isDefault) { + interfaceState.profileStatus = + "The default profile cannot be renamed."; + interfaceState.profileStatusError = true; + } else { + interfaceState.profileMode = ProfileOverlayMode::Rename; + interfaceState.profileInput = selected->name; + interfaceState.profileStatus.clear(); + } + return true; + } + if (event == Event::Character('d')) { + const auto* selected = selectedProfile(); + if (!selected) { + interfaceState.profileStatus = "No profile is selected."; + interfaceState.profileStatusError = true; + } else if (selected->isDefault) { + interfaceState.profileStatus = + "The default profile cannot be deleted."; + interfaceState.profileStatusError = true; + } else { + interfaceState.profileMode = + ProfileOverlayMode::ConfirmDelete; + interfaceState.profileStatus.clear(); + } + return true; + } + if (event == Event::Return) { + const auto* selected = selectedProfile(); + if (!selected) { + interfaceState.profileStatus = "No profile is selected."; + interfaceState.profileStatusError = true; + } else if (selected->id == interfaceState.activeProfileId && + !hasUnsavedProfileChanges(interfaceState)) { + interfaceState.profileStatus = + selected->name + " is already active."; + interfaceState.profileStatusError = false; + } else if (hasUnsavedProfileChanges(interfaceState)) { + interfaceState.pendingProfileId = selected->id; + interfaceState.profileMode = + ProfileOverlayMode::ConfirmLoad; + interfaceState.profileStatus.clear(); + } else if (loadProfile(selected->id)) { + interfaceState.profilesOpen = false; + } + return true; + } + return true; + } + + if (interfaceState.profileMode == ProfileOverlayMode::SaveAs || + interfaceState.profileMode == ProfileOverlayMode::Rename) { + if (event == Event::Escape) { + if (interfaceState.profileMode == ProfileOverlayMode::SaveAs && + !interfaceState.pendingProfileId.empty()) { + interfaceState.profileMode = + ProfileOverlayMode::ConfirmLoad; + interfaceState.profileInput.clear(); + } else { + returnToProfileBrowser(); + } + interfaceState.profileStatus.clear(); + return true; + } + if (event == Event::Backspace) { + eraseLastUtf8Character(interfaceState.profileInput); + interfaceState.profileStatus.clear(); + return true; + } + if (event == Event::Return) { + std::string error; + if (interfaceState.profileMode == ProfileOverlayMode::SaveAs) { + std::string createdId; + if (!profileLibrary.saveNew( + interfaceState.profileInput, + interfaceState.settings, + &createdId, + &error)) { + interfaceState.profileStatus = error; + interfaceState.profileStatusError = true; + return true; + } + const std::string pendingLoad = + interfaceState.pendingProfileId; + syncProfiles(); + selectProfile(createdId); + interfaceState.profileInput.clear(); + interfaceState.pendingProfileId.clear(); + if (!pendingLoad.empty()) { + if (loadProfile(pendingLoad)) { + interfaceState.profilesOpen = false; + } + } else { + interfaceState.profileMode = ProfileOverlayMode::Browse; + interfaceState.profileStatus = "Saved new profile."; + interfaceState.profileStatusError = false; + } + } else { + const auto* selected = selectedProfile(); + if (!selected) { + interfaceState.profileStatus = + "No profile is selected."; + interfaceState.profileStatusError = true; + return true; + } + const std::string id = selected->id; + if (!profileLibrary.renameProfile( + id, interfaceState.profileInput, &error)) { + interfaceState.profileStatus = error; + interfaceState.profileStatusError = true; + return true; + } + syncProfiles(); + selectProfile(id); + interfaceState.profileMode = ProfileOverlayMode::Browse; + interfaceState.profileInput.clear(); + interfaceState.profileStatus = "Profile renamed."; + interfaceState.profileStatusError = false; + } + return true; + } + if (appendProfileNameCharacter( + interfaceState.profileInput, event)) { + interfaceState.profileStatus.clear(); + return true; + } + return true; + } + + if (event == Event::Escape) { + returnToProfileBrowser(); + interfaceState.profileStatus.clear(); + return true; + } + if (interfaceState.profileMode == + ProfileOverlayMode::ConfirmOverwrite && + (event == Event::Return || event == Event::Character('w'))) { + std::string error; + if (!profileLibrary.overwrite( + interfaceState.activeProfileId, + interfaceState.settings, + &error)) { + interfaceState.profileStatus = error; + interfaceState.profileStatusError = true; + } else { + const std::string activeId = interfaceState.activeProfileId; + syncProfiles(); + selectProfile(activeId); + interfaceState.profileMode = ProfileOverlayMode::Browse; + interfaceState.profileStatus = "Active profile overwritten."; + interfaceState.profileStatusError = false; + } + return true; + } + if (interfaceState.profileMode == + ProfileOverlayMode::ConfirmDelete && + event == Event::Character('d')) { + const auto* selected = selectedProfile(); + if (!selected) { + returnToProfileBrowser(); + interfaceState.profileStatus = "No profile is selected."; + interfaceState.profileStatusError = true; + return true; + } + const std::string deletedName = selected->name; + std::string error; + if (!profileLibrary.deleteProfile(selected->id, &error)) { + interfaceState.profileStatus = error; + interfaceState.profileStatusError = true; + } else { + syncProfiles(); + interfaceState.profileMode = ProfileOverlayMode::Browse; + interfaceState.profileStatus = "Deleted " + deletedName; + interfaceState.profileStatusError = false; + } + return true; + } + if (interfaceState.profileMode == ProfileOverlayMode::ConfirmLoad) { + if (event == Event::Character('n')) { + interfaceState.profileMode = ProfileOverlayMode::SaveAs; + interfaceState.profileInput.clear(); + interfaceState.profileStatus.clear(); + return true; + } + if (event == Event::Character('w') && + !interfaceState.activeProfileId.empty()) { + std::string error; + if (!profileLibrary.overwrite( + interfaceState.activeProfileId, + interfaceState.settings, + &error)) { + interfaceState.profileStatus = error; + interfaceState.profileStatusError = true; + return true; + } + syncProfiles(); + const std::string pending = interfaceState.pendingProfileId; + interfaceState.pendingProfileId.clear(); + if (loadProfile(pending)) { + interfaceState.profilesOpen = false; + } + return true; + } + if (event == Event::Character('d')) { + const std::string pending = interfaceState.pendingProfileId; + interfaceState.pendingProfileId.clear(); + if (loadProfile(pending)) { + interfaceState.profilesOpen = false; + } + return true; + } + } + return true; + } + if (event == Event::Character('q')) { + running.store(false); + exitLoop(); + return true; + } + if (event == Event::Character('p') && + !interfaceState.settingsOpen && !interfaceState.layoutEditing) { + interfaceState.profilesOpen = true; + interfaceState.profileMode = ProfileOverlayMode::Browse; + interfaceState.profileInput.clear(); + interfaceState.pendingProfileId.clear(); + if (!interfaceState.profiles.empty()) { + interfaceState.profileStatus.clear(); + interfaceState.profileStatusError = false; + } + if (!interfaceState.activeProfileId.empty()) { + selectProfile(interfaceState.activeProfileId); + } + return true; + } + if (event == Event::Character('l') && !interfaceState.settingsOpen) { + interfaceState.layoutEditing = !interfaceState.layoutEditing; + interfaceState.expandedPanel.reset(); + interfaceState.layoutOverlay = LayoutOverlay::None; + interfaceState.layoutAddSelection = 0; + if (interfaceState.layoutEditing) { + interfaceState.layoutStatus = + "Arrows select • Shift+arrows move • a restores scopes"; + const auto editableLayout = buildDashboardLayout( + screen.dimx(), + screen.dimy(), + interfaceState.settings.rackLayout); + if (!layoutContainsPanel( + editableLayout, interfaceState.focusedPanel)) { + const auto visible = visiblePanelOrder(editableLayout); + if (!visible.empty()) { + interfaceState.focusedPanel = visible.front(); + } + } + } else { + interfaceState.layoutStatus.clear(); + } + if (!interfaceState.layoutEditing) persistSettings(); + return true; + } if (event == Event::Character('s')) { + if (interfaceState.layoutEditing) { + interfaceState.layoutEditing = false; + interfaceState.layoutOverlay = LayoutOverlay::None; + interfaceState.layoutStatus.clear(); + persistSettings(); + } if (interfaceState.settingsOpen) { closeSettings(); } else { @@ -1723,6 +2591,220 @@ int runInteractive(std::unique_ptr capture, } return true; } + if (interfaceState.layoutEditing) { + if (interfaceState.layoutOverlay == LayoutOverlay::Help) { + if (event == Event::Escape || event == Event::Return || + event == Event::Character('?') || + event == Event::Character('h')) { + interfaceState.layoutOverlay = LayoutOverlay::None; + } else if (event == Event::Character('a')) { + interfaceState.layoutOverlay = LayoutOverlay::AddScope; + interfaceState.layoutAddSelection = 0; + } + return true; + } + if (interfaceState.layoutOverlay == LayoutOverlay::AddScope) { + const auto removed = removedRackPanels( + interfaceState.settings.rackLayout); + if (event == Event::Escape || event == Event::Character('a')) { + interfaceState.layoutOverlay = LayoutOverlay::None; + return true; + } + if (event == Event::Character('?') || + event == Event::Character('h')) { + interfaceState.layoutOverlay = LayoutOverlay::Help; + return true; + } + if (!removed.empty() && + (event == Event::ArrowUp || event == Event::ArrowDown)) { + const int direction = event == Event::ArrowDown ? 1 : -1; + const int count = static_cast(removed.size()); + interfaceState.layoutAddSelection = static_cast( + (static_cast(std::min( + interfaceState.layoutAddSelection, + removed.size() - 1)) + direction + count) % count); + return true; + } + if (event == Event::Return) { + if (removed.empty()) { + interfaceState.layoutStatus = + "All scopes are already in the rack"; + } else { + const PanelId added = removed[std::min( + interfaceState.layoutAddSelection, + removed.size() - 1)]; + if (addRackPanel( + interfaceState.settings.rackLayout, + added, + interfaceState.focusedPanel)) { + interfaceState.focusedPanel = added; + interfaceState.layoutStatus = addedScopeStatus(added); + persistSettings(); + } + } + interfaceState.layoutOverlay = LayoutOverlay::None; + interfaceState.layoutAddSelection = 0; + return true; + } + return true; + } + if (event == Event::Character('?') || + event == Event::Character('h')) { + interfaceState.layoutOverlay = LayoutOverlay::Help; + interfaceState.layoutStatus.clear(); + return true; + } + if (event == Event::Character('a')) { + interfaceState.layoutOverlay = LayoutOverlay::AddScope; + interfaceState.layoutAddSelection = 0; + interfaceState.layoutStatus.clear(); + return true; + } + if (event == Event::Escape || event == Event::Return) { + interfaceState.layoutEditing = false; + interfaceState.layoutOverlay = LayoutOverlay::None; + interfaceState.layoutStatus.clear(); + persistSettings(); + return true; + } + if (event == Event::Tab || event == Event::TabReverse) { + const auto navigationLayout = buildDashboardLayout( + screen.dimx(), + screen.dimy(), + interfaceState.settings.rackLayout); + interfaceState.focusedPanel = nextPanel( + interfaceState.focusedPanel, + visiblePanelOrder(navigationLayout), + event == Event::TabReverse); + interfaceState.layoutStatus = + "Selected " + panelName(interfaceState.focusedPanel); + return true; + } + if (const auto direction = plainArrowDirection(event)) { + const auto navigationLayout = buildDashboardLayout( + screen.dimx(), + screen.dimy(), + interfaceState.settings.rackLayout); + if (const auto neighbor = spatialNeighbor( + navigationLayout, + interfaceState.focusedPanel, + *direction)) { + interfaceState.focusedPanel = *neighbor; + interfaceState.layoutStatus = + "Selected " + panelName(*neighbor); + } else { + interfaceState.layoutStatus = "No scope in that direction"; + } + return true; + } + + bool changed = false; + std::string feedback; + if (const auto direction = moveArrowDirection(event)) { + if (*direction == NavigationDirection::Left || + *direction == NavigationDirection::Right) { + const int movement = *direction == NavigationDirection::Left + ? -1 + : 1; + changed = moveRackPanelHorizontal( + interfaceState.settings.rackLayout, + interfaceState.focusedPanel, + movement); + feedback = changed + ? "Moved " + panelName(interfaceState.focusedPanel) + + (movement < 0 ? " left" : " right") + : movement < 0 + ? "Already first in this row" + : "Already last in this row"; + } else { + const int movement = *direction == NavigationDirection::Up + ? -1 + : 1; + changed = moveRackPanelVertical( + interfaceState.settings.rackLayout, + interfaceState.focusedPanel, + movement); + feedback = changed + ? "Moved " + panelName(interfaceState.focusedPanel) + + (movement < 0 ? " up" : " down") + : movement < 0 ? "No row above" : "No row below"; + } + } else if (event == Event::Character('[')) { + changed = resizeRackPanel( + interfaceState.settings.rackLayout, + interfaceState.focusedPanel, + -1); + feedback = changed ? "Reduced scope width" : "Minimum scope width"; + } else if (event == Event::Character(']')) { + changed = resizeRackPanel( + interfaceState.settings.rackLayout, + interfaceState.focusedPanel, + 1); + feedback = changed ? "Increased scope width" : "Maximum scope width"; + } else if (event == Event::Character(',')) { + changed = resizeRackRow( + interfaceState.settings.rackLayout, + interfaceState.focusedPanel, + -1); + feedback = changed ? "Reduced row height" : "Minimum row height"; + } else if (event == Event::Character('.')) { + changed = resizeRackRow( + interfaceState.settings.rackLayout, + interfaceState.focusedPanel, + 1); + feedback = changed ? "Increased row height" : "Maximum row height"; + } else if (event == Event::Character('n')) { + changed = splitRackRow( + interfaceState.settings.rackLayout, + interfaceState.focusedPanel); + feedback = changed + ? "Created a new row for " + panelName(interfaceState.focusedPanel) + : "Cannot create another row here"; + } else if (event == Event::Character('x')) { + const PanelId removedPanel = interfaceState.focusedPanel; + const auto configured = configuredPanelOrder( + interfaceState.settings.rackLayout); + const auto selected = std::find( + configured.begin(), configured.end(), interfaceState.focusedPanel); + PanelId nextFocus = interfaceState.focusedPanel; + if (configured.size() > 1 && selected != configured.end()) { + const size_t selectedIndex = static_cast( + std::distance(configured.begin(), selected)); + nextFocus = configured[ + selectedIndex + 1 < configured.size() + ? selectedIndex + 1 + : selectedIndex - 1]; + } + changed = removeRackPanel( + interfaceState.settings.rackLayout, + interfaceState.focusedPanel); + if (changed) { + interfaceState.focusedPanel = nextFocus; + feedback = "Removed " + panelName(removedPanel) + + " • a adds it back"; + } else { + feedback = "At least one scope must remain"; + } + } else if (const auto selectedPanel = panelForEvent(event)) { + if (rackPanelLocation( + interfaceState.settings.rackLayout, *selectedPanel)) { + interfaceState.focusedPanel = *selectedPanel; + feedback = "Selected " + panelName(*selectedPanel); + } else { + changed = addRackPanel( + interfaceState.settings.rackLayout, + *selectedPanel, + interfaceState.focusedPanel); + if (changed) { + interfaceState.focusedPanel = *selectedPanel; + feedback = addedScopeStatus(*selectedPanel); + } + } + } + if (!feedback.empty()) interfaceState.layoutStatus = feedback; + if (changed) persistSettings(); + return true; + } if (interfaceState.settingsOpen) { if (event == Event::Escape) { if (interfaceState.settingsPage == SettingsPage::Home) { @@ -1807,17 +2889,11 @@ int runInteractive(std::unique_ptr capture, resetRequested.store(true); return true; } - if (event == Event::Character('v')) { - adjustSetting( - interfaceState.settings, SettingId::VectorscopeMode, 1); - persistSettings(); - return true; - } if (event == Event::Tab || event == Event::TabReverse) { const auto navigationLayout = buildDashboardLayout( screen.dimx(), screen.dimy(), - interfaceState.settings.layoutPreset, + interfaceState.settings.rackLayout, interfaceState.expandedPanel); const auto navigationPanels = interfaceState.expandedPanel ? panelOrder() @@ -1839,35 +2915,13 @@ int runInteractive(std::unique_ptr capture, } return true; } - if (event == Event::Character('l')) { - adjustSetting(interfaceState.settings, SettingId::Layout, 1); - persistSettings(); - interfaceState.expandedPanel.reset(); - const auto nextLayout = buildDashboardLayout( - screen.dimx(), screen.dimy(), interfaceState.settings.layoutPreset); - if (!layoutContainsPanel(nextLayout, interfaceState.focusedPanel)) { - const auto visible = visiblePanelOrder(nextLayout); - if (!visible.empty()) { - interfaceState.focusedPanel = visible.front(); - } - } - return true; - } - std::optional selectedPanel; - if (event == Event::Character('1')) selectedPanel = PanelId::Spectrum; - if (event == Event::Character('2')) selectedPanel = PanelId::Oscilloscope; - if (event == Event::Character('3')) selectedPanel = PanelId::Vectorscope; - if (event == Event::Character('4')) selectedPanel = PanelId::VUMeter; - if (event == Event::Character('5')) selectedPanel = PanelId::LUFSMeter; - if (event == Event::Character('6')) selectedPanel = PanelId::Spectrogram; - if (event == Event::Character('7')) selectedPanel = PanelId::Waveform; - if (selectedPanel) { + if (const auto selectedPanel = panelForEvent(event)) { interfaceState.focusedPanel = *selectedPanel; if (interfaceState.expandedPanel) { interfaceState.expandedPanel = interfaceState.focusedPanel; } else { const auto currentLayout = buildDashboardLayout( - screen.dimx(), screen.dimy(), interfaceState.settings.layoutPreset); + screen.dimx(), screen.dimy(), interfaceState.settings.rackLayout); if (!layoutContainsPanel(currentLayout, interfaceState.focusedPanel)) { interfaceState.expandedPanel = interfaceState.focusedPanel; } diff --git a/tui/src/tui_settings.cpp b/tui/src/tui_settings.cpp index 6a317c6..368f952 100644 --- a/tui/src/tui_settings.cpp +++ b/tui/src/tui_settings.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include namespace Prism::Tui { namespace { @@ -14,7 +16,6 @@ namespace { const std::vector kGeneralSettings = { {SettingId::InputTrim, "Input trim", "Applies gain before every analyzer."}, {SettingId::RefreshRate, "Refresh rate", "Controls how often the terminal display is published."}, - {SettingId::Layout, "Dashboard layout", "Chooses automatic, stacked, or column panes."}, }; const std::vector 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(input), + std::istreambuf_iterator())); +} + +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; diff --git a/tui/src/tui_settings.h b/tui/src/tui_settings.h index 0fcb317..5badb61 100644 --- a/tui/src/tui_settings.h +++ b/tui/src/tui_settings.h @@ -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, diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index 6b1afac..7d692ce 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -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 #include #include +#include #include +#include #include #include #include @@ -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(input), + std::istreambuf_iterator()}; + 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();