diff --git a/README.md b/README.md index 591778f..236a4d6 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,9 @@ Capture-to-display latency measures under 8ms. When tested at 120fps, measured l Installable Prism packages also provide `prism-tui`, a native terminal frontend for the shared C++ capture and analysis engine. It shows a responsive spectrum, -stereo VU meters, and momentary, short-term, and integrated LUFS readings. +stereo VU meters, and momentary, short-term, and integrated LUFS readings. The +dashboard uses a 4096-point FFT by default and automatically switches between +stacked and column layouts as the terminal is resized. ```bash prism-tui # Capture the default system output @@ -61,11 +63,13 @@ prism-tui --help prism-tui --version ``` -Press `r` to reset the analyzers and integrated loudness, or `q`, Escape, or -Ctrl-C to quit. Interactive mode requires a terminal of at least 44 by 12 cells. -Quote a device ID if it contains spaces. Successful help, version, listing, and -interactive exits return `0`; usage errors return `2`; capture and runtime -failures return `1`. +Press Tab or Shift-Tab to focus a panel, Enter to expand or restore it, and `l` +to cycle automatic, stacked, and column layouts. Number keys `1` and `2` focus +Spectrum and Levels. Press `r` to reset the analyzers and integrated loudness, +or `q`, Escape, or Ctrl-C to quit. Interactive mode requires a terminal of at +least 44 by 12 cells. Quote a device ID if it contains spaces. Successful help, +version, listing, and interactive exits return `0`; usage errors return `2`; +capture and runtime failures return `1`. The v0 TUI captures system output only. Microphone/device-input capture, Prism profiles and themes, file/stdin analysis, and the other visualizers remain GUI diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index e2394bf..78cce9c 100644 --- a/tui/CMakeLists.txt +++ b/tui/CMakeLists.txt @@ -31,6 +31,7 @@ find_package(Threads REQUIRED) add_library(prism_tui_analysis STATIC src/analysis_pipeline.cpp src/cli.cpp + src/dashboard_layout.cpp src/display_model.cpp ${PRISM_NATIVE_DIR}/spectrum.cpp ${PRISM_NATIVE_DIR}/vumeter.cpp diff --git a/tui/src/analysis_pipeline.h b/tui/src/analysis_pipeline.h index aff014d..ac1c52d 100644 --- a/tui/src/analysis_pipeline.h +++ b/tui/src/analysis_pipeline.h @@ -9,6 +9,8 @@ namespace Prism::Tui { +constexpr size_t kDefaultFftSize = 4096; + struct AnalysisFrame { std::vector magnitudes; Visualizer::VUMeterSnapshot vu{}; @@ -17,7 +19,7 @@ struct AnalysisFrame { class AnalysisPipeline { public: - explicit AnalysisPipeline(float sampleRate, size_t fftSize = 2048); + explicit AnalysisPipeline(float sampleRate, size_t fftSize = kDefaultFftSize); void process(const Prism::Capture::AudioChunk& chunk); AnalysisFrame snapshot(); diff --git a/tui/src/cli.cpp b/tui/src/cli.cpp index 662ec0b..c168cf8 100644 --- a/tui/src/cli.cpp +++ b/tui/src/cli.cpp @@ -56,7 +56,14 @@ std::string usageText() { " --device Capture a specific system output device.\n" " --list-devices List available system output devices.\n" " -h, --help Show this help.\n" - " -V, --version Show the Prism TUI version.\n"; + " -V, --version Show the Prism TUI version.\n\n" + "Controls:\n" + " Tab / Shift-Tab Focus the next or previous panel.\n" + " Enter Expand the focused panel or restore the dashboard.\n" + " l Cycle automatic, stacked, and column layouts.\n" + " 1 / 2 Focus Spectrum or Levels.\n" + " r Reset analyzers and integrated loudness.\n" + " q / Esc / Ctrl-C Quit.\n"; } } // namespace Prism::Tui diff --git a/tui/src/dashboard_layout.cpp b/tui/src/dashboard_layout.cpp new file mode 100644 index 0000000..0e9a967 --- /dev/null +++ b/tui/src/dashboard_layout.cpp @@ -0,0 +1,241 @@ +#include "dashboard_layout.h" + +#include +#include +#include + +namespace Prism::Tui { +namespace { + +struct MinimumSize { + int width = 1; + int height = 1; +}; + +MinimumSize panelMinimumSize(PanelId panel) { + switch (panel) { + case PanelId::Spectrum: + return {30, 5}; + case PanelId::Levels: + return {30, 5}; + } + return {1, 1}; +} + +MinimumSize nodeMinimumSize(const LayoutNode& node) { + if (node.isLeaf()) { + return panelMinimumSize(*node.panel); + } + + MinimumSize result; + result.width = node.axis == SplitAxis::Columns ? 0 : 1; + result.height = node.axis == SplitAxis::Rows ? 0 : 1; + for (const auto& child : node.children) { + const auto childMinimum = nodeMinimumSize(child); + if (node.axis == SplitAxis::Columns) { + result.width += childMinimum.width; + result.height = std::max(result.height, childMinimum.height); + } else { + result.width = std::max(result.width, childMinimum.width); + result.height += childMinimum.height; + } + } + return result; +} + +std::vector partitionExtent(int total, + const std::vector& weights, + const std::vector& minimums) { + if (weights.empty()) { + return {}; + } + + std::vector result(weights.size(), 0); + const int minimumTotal = std::accumulate(minimums.begin(), minimums.end(), 0); + if (minimumTotal <= total) { + int remaining = total; + int remainingWeight = std::accumulate(weights.begin(), weights.end(), 0); + for (size_t index = 0; index < result.size(); ++index) { + const int weight = std::max(1, weights[index]); + result[index] = index + 1 == result.size() + ? remaining + : remaining * weight / std::max(1, remainingWeight); + remaining -= result[index]; + remainingWeight -= weight; + } + + // Preserve the requested ratio whenever possible, then borrow from + // larger panes to honor each panel's usable minimum size. + for (size_t index = 0; index < result.size(); ++index) { + int needed = std::max(0, minimums[index] - result[index]); + for (size_t donor = 0; donor < result.size() && needed > 0; ++donor) { + if (donor == index) continue; + const int available = std::max(0, result[donor] - minimums[donor]); + const int transfer = std::min(needed, available); + result[donor] -= transfer; + result[index] += transfer; + needed -= transfer; + } + } + return result; + } + + int remaining = std::max(0, total); + int remainingWeight = std::accumulate(weights.begin(), weights.end(), 0); + for (size_t index = 0; index < result.size(); ++index) { + const int weight = std::max(1, weights[index]); + result[index] = index + 1 == result.size() + ? remaining + : remaining * weight / std::max(1, remainingWeight); + remaining -= result[index]; + remainingWeight -= weight; + } + return result; +} + +void resolveNode(const LayoutNode& node, + int x, + int y, + int width, + int height, + std::vector& output) { + if (node.isLeaf()) { + output.push_back({*node.panel, x, y, width, height}); + return; + } + if (node.children.empty()) { + return; + } + + std::vector weights; + std::vector minimums; + weights.reserve(node.children.size()); + minimums.reserve(node.children.size()); + for (const auto& child : node.children) { + weights.push_back(std::max(1, child.weight)); + const auto minimum = nodeMinimumSize(child); + minimums.push_back(node.axis == SplitAxis::Columns + ? minimum.width + : minimum.height); + } + + const int extent = node.axis == SplitAxis::Columns ? width : height; + const auto spans = partitionExtent(extent, weights, minimums); + int offset = 0; + for (size_t index = 0; index < node.children.size(); ++index) { + if (node.axis == SplitAxis::Columns) { + resolveNode(node.children[index], x + offset, y, spans[index], height, output); + } else { + resolveNode(node.children[index], x, y + offset, width, spans[index], output); + } + offset += spans[index]; + } +} + +LayoutPreset resolvePreset(LayoutPreset requested, int width, int height) { + constexpr int minimumColumnsWidth = 72; + if (requested == LayoutPreset::Columns && width < minimumColumnsWidth) { + return LayoutPreset::Stacked; + } + if (requested != LayoutPreset::Automatic) { + return requested; + } + return width >= 96 && height >= 28 + ? LayoutPreset::Columns + : LayoutPreset::Stacked; +} + +LayoutNode makeRoot(LayoutPreset preset) { + if (preset == LayoutPreset::Columns) { + return LayoutNode::split(SplitAxis::Columns, { + LayoutNode::leaf(PanelId::Spectrum, 4), + LayoutNode::leaf(PanelId::Levels, 1), + }); + } + return LayoutNode::split(SplitAxis::Rows, { + LayoutNode::leaf(PanelId::Spectrum, 4), + LayoutNode::leaf(PanelId::Levels, 1), + }); +} + +} // namespace + +LayoutNode LayoutNode::leaf(PanelId panel, int weight) { + LayoutNode node; + node.panel = panel; + node.weight = weight; + return node; +} + +LayoutNode LayoutNode::split(SplitAxis axis, + std::vector children, + int weight) { + LayoutNode node; + node.axis = axis; + node.weight = weight; + node.children = std::move(children); + return node; +} + +DashboardLayout buildDashboardLayout(int width, + int height, + LayoutPreset requestedPreset, + std::optional expandedPanel) { + DashboardLayout layout; + layout.requestedPreset = requestedPreset; + 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); + + // The header and footer each consume one terminal row. + resolveNode(layout.root, 0, 0, width, height - 2, layout.panels); + 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; +} + +std::string layoutPresetName(LayoutPreset preset) { + switch (preset) { + case LayoutPreset::Automatic: + return "auto"; + case LayoutPreset::Stacked: + return "stacked"; + case LayoutPreset::Columns: + return "columns"; + } + return "auto"; +} + +std::vector panelOrder() { + return {PanelId::Spectrum, PanelId::Levels}; +} + +PanelId nextPanel(PanelId panel, bool reverse) { + const auto panels = panelOrder(); + const auto found = std::find(panels.begin(), panels.end(), panel); + const size_t index = found == panels.end() + ? 0 + : static_cast(std::distance(panels.begin(), found)); + if (reverse) { + return panels[(index + panels.size() - 1) % panels.size()]; + } + return panels[(index + 1) % panels.size()]; +} + +} // namespace Prism::Tui diff --git a/tui/src/dashboard_layout.h b/tui/src/dashboard_layout.h new file mode 100644 index 0000000..206a585 --- /dev/null +++ b/tui/src/dashboard_layout.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include + +namespace Prism::Tui { + +enum class PanelId { + Spectrum, + Levels, +}; + +enum class SplitAxis { + Rows, + Columns, +}; + +enum class LayoutPreset { + Automatic, + Stacked, + Columns, +}; + +struct LayoutNode { + std::optional panel; + SplitAxis axis = SplitAxis::Rows; + int weight = 1; + std::vector children; + + static LayoutNode leaf(PanelId panel, int weight = 1); + static LayoutNode split(SplitAxis axis, + std::vector children, + int weight = 1); + bool isLeaf() const { return panel.has_value(); } +}; + +struct PanelRect { + PanelId panel = PanelId::Spectrum; + int x = 0; + int y = 0; + int width = 0; + int height = 0; +}; + +struct DashboardLayout { + bool terminalTooSmall = true; + LayoutPreset requestedPreset = LayoutPreset::Automatic; + LayoutPreset resolvedPreset = LayoutPreset::Stacked; + LayoutNode root; + std::vector panels; +}; + +constexpr int kMinimumTerminalWidth = 44; +constexpr int kMinimumTerminalHeight = 12; + +DashboardLayout buildDashboardLayout(int width, + int height, + LayoutPreset requestedPreset, + std::optional expandedPanel = std::nullopt); +LayoutPreset nextLayoutPreset(LayoutPreset preset); +std::string layoutPresetName(LayoutPreset preset); +std::vector panelOrder(); +PanelId nextPanel(PanelId panel, bool reverse = false); + +} // namespace Prism::Tui diff --git a/tui/src/display_model.cpp b/tui/src/display_model.cpp index 978175d..c780be4 100644 --- a/tui/src/display_model.cpp +++ b/tui/src/display_model.cpp @@ -29,18 +29,6 @@ void placeLabel(std::string& axis, size_t position, const std::string& label) { } // namespace -LayoutModel calculateLayout(int width, int height) { - LayoutModel model; - model.terminalTooSmall = width < 44 || height < 12; - if (model.terminalTooSmall) { - return model; - } - model.contentWidth = static_cast(std::max(8, width - 4)); - model.spectrumRowCount = static_cast(std::max(2, height - 10)); - model.meterWidth = static_cast(std::max(8, width - 19)); - return model; -} - std::vector projectSpectrum(const std::vector& magnitudes, size_t fftSize, size_t columns, diff --git a/tui/src/display_model.h b/tui/src/display_model.h index 653e8d0..77e752c 100644 --- a/tui/src/display_model.h +++ b/tui/src/display_model.h @@ -15,15 +15,6 @@ struct SpectrumProjectionOptions { float tiltReferenceHz = 1000.0f; }; -struct LayoutModel { - bool terminalTooSmall = true; - size_t contentWidth = 0; - size_t spectrumRowCount = 0; - size_t meterWidth = 0; -}; - -LayoutModel calculateLayout(int width, int height); - std::vector projectSpectrum(const std::vector& magnitudes, size_t fftSize, size_t columns, diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index 17bff53..670ac10 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -1,6 +1,7 @@ #include "tui_runtime.h" #include "analysis_pipeline.h" +#include "dashboard_layout.h" #include "display_model.h" #include "snapshot_store.h" @@ -32,7 +33,6 @@ namespace Prism::Tui { namespace { -constexpr size_t kFftSize = 2048; constexpr auto kCapturePollInterval = std::chrono::milliseconds(2); constexpr auto kDisplayFrameInterval = std::chrono::milliseconds(33); @@ -69,72 +69,243 @@ struct DisplayFrame { bool captureOverrun = false; }; -std::string makeFooter(const DisplayFrame& frame) { +struct InterfaceState { + PanelId focusedPanel = PanelId::Spectrum; + LayoutPreset layoutPreset = LayoutPreset::Automatic; + std::optional expandedPanel; +}; + +std::string makeCaptureStatus(const DisplayFrame& frame, bool compact) { std::ostringstream sampleRate; const double kilohertz = frame.sampleRate / 1000.0; sampleRate << std::fixed << std::setprecision( std::abs(kilohertz - std::round(kilohertz)) < 0.01 ? 0 : 1) << kilohertz; - std::string footer = frame.backend + " • " + frame.device + " • " + - sampleRate.str() + " kHz"; + std::string footer = frame.backend + " • "; + if (!compact) { + footer += frame.device + " • "; + } + footer += sampleRate.str() + " kHz"; if (frame.captureOverrun) { footer += " • capture overrun"; } - footer += " r reset • q/Esc/Ctrl-C quit"; return footer; } -ftxui::Element renderFrame(const DisplayFrame& frame, int width, int height) { - using namespace ftxui; - const auto layout = calculateLayout(width, height); - if (layout.terminalTooSmall) { - return vbox({ - filler(), - text("Prism TUI") | bold | center, - text("Terminal too small — need at least 44 × 12") | center, - text("q quit") | dim | center, - filler(), - }); +std::string panelName(PanelId panel) { + switch (panel) { + case PanelId::Spectrum: + return "Spectrum"; + case PanelId::Levels: + return "Levels"; } + return "Panel"; +} + +ftxui::Element panelTitle(PanelId panel, bool focused) { + using namespace ftxui; + const std::string number = panel == PanelId::Spectrum ? "1" : "2"; + std::string label = " " + number + " " + panelName(panel); + if (panel == PanelId::Spectrum) { + label += " • FFT " + std::to_string(kDefaultFftSize); + } + label += " "; + auto title = text(label); + return focused + ? title | color(Color::CyanLight) | bold + : title | color(Color::GrayDark); +} + +ftxui::Element stylePanel(ftxui::Element content, bool focused) { + using namespace ftxui; + return content | color(focused ? Color::GrayLight : Color::GrayDark); +} + +ftxui::Element renderSpectrumPanel(const DisplayFrame& frame, + int width, + int height, + bool focused) { + using namespace ftxui; + const size_t contentWidth = static_cast(std::max(1, width - 2)); + const size_t contentHeight = static_cast(std::max(1, height - 2)); + const size_t spectrumRows = contentHeight > 1 ? contentHeight - 1 : 1; SpectrumProjectionOptions projectionOptions; projectionOptions.sampleRate = static_cast(frame.sampleRate); projectionOptions.maxFrequency = std::min(20000.0f, projectionOptions.sampleRate * 0.5f); const auto projected = projectSpectrum( frame.magnitudes, - kFftSize, - layout.contentWidth, + kDefaultFftSize, + contentWidth, projectionOptions); - const auto spectrumRows = buildSpectrumRows(projected, layout.spectrumRowCount); + const auto rows = buildSpectrumRows(projected, spectrumRows); Elements spectrumElements; - spectrumElements.reserve(spectrumRows.size() + 1); - for (const auto& row : spectrumRows) { + spectrumElements.reserve(rows.size() + 1); + for (const auto& row : rows) { spectrumElements.push_back(text(row) | color(Color::Cyan)); } - spectrumElements.push_back( - text(buildFrequencyAxis(layout.contentWidth, projectionOptions.maxFrequency)) | dim); + if (contentHeight > 1) { + spectrumElements.push_back( + text(buildFrequencyAxis(contentWidth, projectionOptions.maxFrequency)) | + color(Color::GrayDark)); + } + + auto panel = window( + panelTitle(PanelId::Spectrum, focused), + vbox(std::move(spectrumElements))); + return stylePanel(std::move(panel), focused) | + size(WIDTH, EQUAL, std::max(1, width)) | + size(HEIGHT, EQUAL, std::max(1, height)); +} + +ftxui::Element renderLevelsPanel(const DisplayFrame& frame, + int width, + int height, + bool focused) { + using namespace ftxui; + const int contentWidth = std::max(1, width - 2); + const int contentHeight = std::max(1, height - 2); + const size_t meterWidth = static_cast(std::max(4, contentWidth - 14)); const auto meterRow = [&](const char* label, float level, float peak) { return hbox({ text(std::string(label) + " ") | bold, - text(buildMeterBar(level, peak, layout.meterWidth)) | color(Color::Cyan), + text(buildMeterBar(level, peak, meterWidth)) | color(Color::Cyan), text(" " + formatDb(level) + " dB"), }); }; - const std::string lufs = - "LUFS M " + formatLufs(frame.lufs.momentaryLUFS) + - " S " + formatLufs(frame.lufs.shortTermLUFS) + - " I " + formatLufs(frame.lufs.integratedLUFS); + Elements body; + body.push_back(meterRow("L", frame.vu.barLDb, frame.vu.peakLDb)); + body.push_back(meterRow("R", frame.vu.barRDb, frame.vu.peakRDb)); + if (contentHeight >= 7) { + body.push_back(separatorEmpty()); + const auto loudnessRow = [&](const char* label, float value) { + return hbox({ + text(label) | color(Color::Yellow), + filler(), + text(formatLufs(value) + " LUFS") | color(Color::YellowLight), + }); + }; + body.push_back(loudnessRow("Momentary", frame.lufs.momentaryLUFS)); + body.push_back(loudnessRow("Short term", frame.lufs.shortTermLUFS)); + body.push_back(loudnessRow("Integrated", frame.lufs.integratedLUFS)); + } else { + const std::string lufs = + "LUFS M " + formatLufs(frame.lufs.momentaryLUFS) + + " S " + formatLufs(frame.lufs.shortTermLUFS) + + " I " + formatLufs(frame.lufs.integratedLUFS); + body.push_back(text(lufs) | color(Color::Yellow)); + } + while (static_cast(body.size()) < contentHeight) { + body.push_back(filler()); + } + + auto panel = window( + panelTitle(PanelId::Levels, focused), + vbox(std::move(body))); + return stylePanel(std::move(panel), focused) | + size(WIDTH, EQUAL, std::max(1, width)) | + size(HEIGHT, EQUAL, std::max(1, height)); +} + +const PanelRect* findPanelRect(const DashboardLayout& layout, PanelId panel) { + const auto found = std::find_if( + layout.panels.begin(), layout.panels.end(), + [panel](const PanelRect& rect) { return rect.panel == panel; }); + return found == layout.panels.end() ? nullptr : &*found; +} + +ftxui::Element renderLayoutNode(const LayoutNode& node, + const DashboardLayout& layout, + const DisplayFrame& frame, + PanelId focusedPanel) { + using namespace ftxui; + if (node.isLeaf()) { + const auto* rect = findPanelRect(layout, *node.panel); + if (rect == nullptr) { + return emptyElement(); + } + const bool focused = *node.panel == focusedPanel; + switch (*node.panel) { + case PanelId::Spectrum: + return renderSpectrumPanel(frame, rect->width, rect->height, focused); + case PanelId::Levels: + return renderLevelsPanel(frame, rect->width, rect->height, focused); + } + } + + Elements children; + children.reserve(node.children.size()); + for (const auto& child : node.children) { + children.push_back(renderLayoutNode(child, layout, frame, focusedPanel)); + } + return node.axis == SplitAxis::Columns + ? hbox(std::move(children)) + : vbox(std::move(children)); +} + +ftxui::Element renderHeader(const DashboardLayout& layout, + const InterfaceState& state) { + using namespace ftxui; + std::string layoutName = layoutPresetName(state.layoutPreset); + if (state.layoutPreset == LayoutPreset::Automatic) { + layoutName += "→" + layoutPresetName(layout.resolvedPreset); + } + return hbox({ + text(" PRISM") | color(Color::CyanLight) | bold, + text(" TUI") | bold, + filler(), + state.expandedPanel + ? text("FOCUS • " + panelName(*state.expandedPanel) + " ") | color(Color::CyanLight) + : text(layoutName + " ") | dim, + }); +} + +ftxui::Element renderFooter(const DisplayFrame& frame, + const InterfaceState& state, + int width) { + using namespace ftxui; + const bool compact = width < 108; + const bool minimal = width < 64; + const std::string enterAction = state.expandedPanel ? "restore" : "expand"; + const std::string controls = minimal + ? "Tab • Enter • l • q" + : compact + ? "Tab focus • Enter " + enterAction + " • l layout • q quit" + : "Tab focus • Enter " + enterAction + " • l layout • r reset • q quit"; + auto status = text(makeCaptureStatus(frame, compact)) | dim; + if (frame.captureOverrun) { + status = status | color(Color::RedLight); + } + return hbox({ + status, + filler(), + text(controls) | dim, + }); +} + +ftxui::Element renderFrame(const DisplayFrame& frame, + int width, + int height, + const InterfaceState& state) { + using namespace ftxui; + const auto layout = buildDashboardLayout( + width, height, state.layoutPreset, state.expandedPanel); + if (layout.terminalTooSmall) { + return vbox({ + filler(), + text("PRISM TUI") | bold | color(Color::CyanLight) | center, + text("Terminal too small — need at least 44 × 12") | center, + text("q quit") | dim | center, + filler(), + }); + } return vbox({ - text("PRISM TUI") | bold | center, - window(text(" Spectrum ") | bold, vbox(std::move(spectrumElements))) | flex, - meterRow("L", frame.vu.barLDb, frame.vu.peakLDb), - meterRow("R", frame.vu.barRDb, frame.vu.peakRDb), - text(lufs) | color(Color::Yellow), - separator(), - text(makeFooter(frame)) | dim, + renderHeader(layout, state) | size(HEIGHT, EQUAL, 1), + renderLayoutNode(layout.root, layout, frame, state.focusedPanel), + renderFooter(frame, state, width) | size(HEIGHT, EQUAL, 1), }); } @@ -156,8 +327,9 @@ int runInteractive(std::unique_ptr capture, ScreenInteractive screen = ScreenInteractive::Fullscreen(); SnapshotStore frameStore; + InterfaceState interfaceState; DisplayFrame initial; - initial.magnitudes.assign(kFftSize / 2, -100.0f); + initial.magnitudes.assign(kDefaultFftSize / 2, -100.0f); initial.sampleRate = started.sampleRate; initial.backend = capture->backendName(); initial.device = started.deviceLabel.empty() ? started.deviceId : started.deviceLabel; @@ -170,7 +342,7 @@ int runInteractive(std::unique_ptr capture, std::thread worker([&]() { try { - AnalysisPipeline pipeline(static_cast(started.sampleRate), kFftSize); + AnalysisPipeline pipeline(static_cast(started.sampleRate)); bool captureOverrun = false; auto nextFrameAt = std::chrono::steady_clock::now(); @@ -213,7 +385,8 @@ int runInteractive(std::unique_ptr capture, }); auto renderer = Renderer([&]() { - return renderFrame(frameStore.read(), screen.dimx(), screen.dimy()); + return renderFrame( + frameStore.read(), screen.dimx(), screen.dimy(), interfaceState); }); auto component = CatchEvent(renderer, [&](Event event) { if (event == Event::Character('q') || event == Event::Escape || event == Event::CtrlC) { @@ -225,6 +398,37 @@ int runInteractive(std::unique_ptr capture, resetRequested.store(true); return true; } + if (event == Event::Tab || event == Event::TabReverse) { + interfaceState.focusedPanel = nextPanel( + interfaceState.focusedPanel, + event == Event::TabReverse); + if (interfaceState.expandedPanel) { + interfaceState.expandedPanel = interfaceState.focusedPanel; + } + return true; + } + if (event == Event::Return) { + if (interfaceState.expandedPanel) { + interfaceState.expandedPanel.reset(); + } else { + interfaceState.expandedPanel = interfaceState.focusedPanel; + } + return true; + } + if (event == Event::Character('l')) { + interfaceState.layoutPreset = nextLayoutPreset(interfaceState.layoutPreset); + interfaceState.expandedPanel.reset(); + return true; + } + if (event == Event::Character('1') || event == Event::Character('2')) { + interfaceState.focusedPanel = event == Event::Character('1') + ? PanelId::Spectrum + : PanelId::Levels; + if (interfaceState.expandedPanel) { + interfaceState.expandedPanel = interfaceState.focusedPanel; + } + return true; + } return false; }); diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index da6e6c1..bf0d02b 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -1,5 +1,6 @@ #include "analysis_pipeline.h" #include "cli.h" +#include "dashboard_layout.h" #include "display_model.h" #include "snapshot_store.h" #include "system_audio_capture.h" @@ -104,6 +105,8 @@ void testCli() { "duplicate device options should fail"); require(!Prism::Tui::parseArguments({"--help", "--version"}).ok, "exclusive commands should not combine"); + require(Prism::Tui::usageText().find("Tab / Shift-Tab") != std::string::npos, + "help should describe dashboard keyboard controls"); } void testProjectionAndLayout() { @@ -127,8 +130,12 @@ void testProjectionAndLayout() { std::cerr << "Projected 1 kHz peak column: " << peak << '\n'; } require(peak > 55 && peak < 75, "1 kHz peak should land in the logarithmic center region"); - require(Prism::Tui::buildSpectrumRows(projected, 6).size() == 6, + const auto blockRows = Prism::Tui::buildSpectrumRows(projected, 6); + require(blockRows.size() == 6, "spectrum rows should follow the requested height"); + require(std::any_of(blockRows.begin(), blockRows.end(), [](const std::string& row) { + return row.find("█") != std::string::npos; + }), "spectrum should retain its solid block fill style"); require(Prism::Tui::buildSpectrumRows(projected, 0).empty(), "zero-height spectrum should be empty"); const auto meter = Prism::Tui::buildMeterBar(-12.0f, -6.0f, 20); @@ -137,16 +144,49 @@ void testProjectionAndLayout() { require(meter.find("│") != std::string::npos, "meter bar should include its peak marker"); - const auto normal = Prism::Tui::calculateLayout(100, 30); - const auto narrow = Prism::Tui::calculateLayout(44, 12); - require(!normal.terminalTooSmall && normal.spectrumRowCount == 20, - "normal terminal layout should fill available height"); - require(!narrow.terminalTooSmall && narrow.contentWidth == 40, - "minimum terminal layout should remain renderable"); - require(Prism::Tui::calculateLayout(43, 12).terminalTooSmall, + 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() == 2 && + wide.panels[0].width + wide.panels[1].width == 100 && + wide.panels[0].width > wide.panels[1].width, + "column panels should fill the width and favor the spectrum"); + + const auto stacked = Prism::Tui::buildDashboardLayout( + 80, 20, Prism::Tui::LayoutPreset::Automatic); + require(stacked.resolvedPreset == Prism::Tui::LayoutPreset::Stacked, + "short terminals should stack their panels"); + require(stacked.panels.size() == 2 && + stacked.panels[0].height + stacked.panels[1].height == 18 && + stacked.panels[0].height > stacked.panels[1].height, + "stacked panels should fill the dashboard and favor the spectrum"); + + 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, "narrow resize should select the compact screen"); - require(Prism::Tui::calculateLayout(80, 11).terminalTooSmall, + require(Prism::Tui::buildDashboardLayout( + 80, 11, Prism::Tui::LayoutPreset::Automatic).terminalTooSmall, "short resize should select the compact screen"); + + const auto expanded = Prism::Tui::buildDashboardLayout( + 100, 30, Prism::Tui::LayoutPreset::Columns, Prism::Tui::PanelId::Levels); + require(expanded.panels.size() == 1 && + expanded.panels[0].panel == Prism::Tui::PanelId::Levels && + expanded.panels[0].width == 100 && expanded.panels[0].height == 28, + "expanded panels should occupy the complete dashboard area"); + require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum) == + Prism::Tui::PanelId::Levels, + "panel focus should cycle forward"); + require(Prism::Tui::nextPanel(Prism::Tui::PanelId::Spectrum, true) == + Prism::Tui::PanelId::Levels, + "panel focus should cycle backward"); } void testPipelineAndFakeCapture() { @@ -160,6 +200,8 @@ void testPipelineAndFakeCapture() { capture.chunks.push_back(sineChunk(1000.0f, 0.25f, 2400, 48000.0f)); } + require(Prism::Tui::kDefaultFftSize == 4096, + "the TUI spectrum should default to a 4096-point FFT"); Prism::Tui::AnalysisPipeline pipeline(48000.0f); bool captureOverrun = false; capture.nextOverwriteCount = 3; @@ -172,6 +214,8 @@ void testPipelineAndFakeCapture() { worker.join(); require(captureOverrun, "capture draining should publish queue overruns"); const auto frame = pipeline.snapshot(); + require(frame.magnitudes.size() == Prism::Tui::kDefaultFftSize / 2, + "the default analysis pipeline should publish the 4096-point spectrum"); require(frame.vu.barLDb > -20.0f && frame.vu.barLDb < -5.0f, "VU level should reflect deterministic input"); require(std::isfinite(frame.lufs.momentaryLUFS) && frame.lufs.momentaryLUFS > -60.0f,