mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-16 00:00:31 +02:00
early tui layout
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user