early tui layout

This commit is contained in:
Boof2015
2026-08-14 17:15:47 -04:00
parent 7047df3e38
commit e712421bad
10 changed files with 624 additions and 76 deletions
+10 -6
View File
@@ -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
+1
View File
@@ -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
+3 -1
View File
@@ -9,6 +9,8 @@
namespace Prism::Tui {
constexpr size_t kDefaultFftSize = 4096;
struct AnalysisFrame {
std::vector<float> 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();
+8 -1
View File
@@ -56,7 +56,14 @@ std::string usageText() {
" --device <id> 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
+241
View File
@@ -0,0 +1,241 @@
#include "dashboard_layout.h"
#include <algorithm>
#include <numeric>
#include <utility>
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<int> partitionExtent(int total,
const std::vector<int>& weights,
const std::vector<int>& minimums) {
if (weights.empty()) {
return {};
}
std::vector<int> 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<PanelRect>& output) {
if (node.isLeaf()) {
output.push_back({*node.panel, x, y, width, height});
return;
}
if (node.children.empty()) {
return;
}
std::vector<int> weights;
std::vector<int> 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<LayoutNode> 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<PanelId> 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<PanelId> 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<size_t>(std::distance(panels.begin(), found));
if (reverse) {
return panels[(index + panels.size() - 1) % panels.size()];
}
return panels[(index + 1) % panels.size()];
}
} // namespace Prism::Tui
+66
View File
@@ -0,0 +1,66 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
namespace Prism::Tui {
enum class PanelId {
Spectrum,
Levels,
};
enum class SplitAxis {
Rows,
Columns,
};
enum class LayoutPreset {
Automatic,
Stacked,
Columns,
};
struct LayoutNode {
std::optional<PanelId> panel;
SplitAxis axis = SplitAxis::Rows;
int weight = 1;
std::vector<LayoutNode> children;
static LayoutNode leaf(PanelId panel, int weight = 1);
static LayoutNode split(SplitAxis axis,
std::vector<LayoutNode> 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<PanelRect> panels;
};
constexpr int kMinimumTerminalWidth = 44;
constexpr int kMinimumTerminalHeight = 12;
DashboardLayout buildDashboardLayout(int width,
int height,
LayoutPreset requestedPreset,
std::optional<PanelId> expandedPanel = std::nullopt);
LayoutPreset nextLayoutPreset(LayoutPreset preset);
std::string layoutPresetName(LayoutPreset preset);
std::vector<PanelId> panelOrder();
PanelId nextPanel(PanelId panel, bool reverse = false);
} // namespace Prism::Tui
-12
View File
@@ -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<size_t>(std::max(8, width - 4));
model.spectrumRowCount = static_cast<size_t>(std::max(2, height - 10));
model.meterWidth = static_cast<size_t>(std::max(8, width - 19));
return model;
}
std::vector<float> projectSpectrum(const std::vector<float>& magnitudes,
size_t fftSize,
size_t columns,
-9
View File
@@ -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<float> projectSpectrum(const std::vector<float>& magnitudes,
size_t fftSize,
size_t columns,
+242 -38
View File
@@ -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<PanelId> 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<size_t>(std::max(1, width - 2));
const size_t contentHeight = static_cast<size_t>(std::max(1, height - 2));
const size_t spectrumRows = contentHeight > 1 ? contentHeight - 1 : 1;
SpectrumProjectionOptions projectionOptions;
projectionOptions.sampleRate = static_cast<float>(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<size_t>(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<int>(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<Prism::Capture::SystemAudioCapture> capture,
ScreenInteractive screen = ScreenInteractive::Fullscreen();
SnapshotStore<DisplayFrame> 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<Prism::Capture::SystemAudioCapture> capture,
std::thread worker([&]() {
try {
AnalysisPipeline pipeline(static_cast<float>(started.sampleRate), kFftSize);
AnalysisPipeline pipeline(static_cast<float>(started.sampleRate));
bool captureOverrun = false;
auto nextFrameAt = std::chrono::steady_clock::now();
@@ -213,7 +385,8 @@ int runInteractive(std::unique_ptr<Prism::Capture::SystemAudioCapture> 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<Prism::Capture::SystemAudioCapture> 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;
});
+53 -9
View File
@@ -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,