diff --git a/tui/CMakeLists.txt b/tui/CMakeLists.txt index 8870166..3f84555 100644 --- a/tui/CMakeLists.txt +++ b/tui/CMakeLists.txt @@ -33,6 +33,7 @@ add_library(prism_tui_analysis STATIC src/cli.cpp src/dashboard_layout.cpp src/display_model.cpp + src/frame_pacing.cpp src/frame_rate_meter.cpp src/meter_display_model.cpp src/output_selection.cpp diff --git a/tui/src/frame_pacing.cpp b/tui/src/frame_pacing.cpp new file mode 100644 index 0000000..77a747f --- /dev/null +++ b/tui/src/frame_pacing.cpp @@ -0,0 +1,20 @@ +#include "frame_pacing.h" + +namespace Prism::Tui { + +std::chrono::steady_clock::time_point advanceFrameDeadline( + std::chrono::steady_clock::time_point currentDeadline, + std::chrono::steady_clock::time_point renderedAt, + std::chrono::steady_clock::duration frameInterval) { + if (frameInterval <= std::chrono::steady_clock::duration::zero()) { + return renderedAt; + } + + const auto elapsed = renderedAt - currentDeadline; + const auto intervalsToAdvance = elapsed >= decltype(elapsed)::zero() + ? elapsed / frameInterval + 1 + : 1; + return currentDeadline + frameInterval * intervalsToAdvance; +} + +} // namespace Prism::Tui diff --git a/tui/src/frame_pacing.h b/tui/src/frame_pacing.h new file mode 100644 index 0000000..0df2761 --- /dev/null +++ b/tui/src/frame_pacing.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace Prism::Tui { + +// Preserve the cadence anchor while skipping missed slots so late frames never +// create a catch-up backlog. +std::chrono::steady_clock::time_point advanceFrameDeadline( + std::chrono::steady_clock::time_point currentDeadline, + std::chrono::steady_clock::time_point renderedAt, + std::chrono::steady_clock::duration frameInterval); + +} // namespace Prism::Tui diff --git a/tui/src/tui_runtime.cpp b/tui/src/tui_runtime.cpp index 926683c..e44136b 100644 --- a/tui/src/tui_runtime.cpp +++ b/tui/src/tui_runtime.cpp @@ -3,6 +3,7 @@ #include "analysis_pipeline.h" #include "dashboard_layout.h" #include "display_model.h" +#include "frame_pacing.h" #include "frame_rate_meter.h" #include "meter_display_model.h" #include "output_selection.h" @@ -14,6 +15,7 @@ #include #include +#include #include #include #include @@ -23,12 +25,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -2446,8 +2450,30 @@ int runInteractive(std::unique_ptr capture, std::atomic resetRequested{false}; std::atomic redrawQueued{false}; std::atomic outputListRequested{0}; +#if defined(_WIN32) + std::mutex screenWakeMutex; + std::condition_variable screenWakeCondition; + uint64_t screenWakeSerial = 0; +#endif std::exception_ptr workerError; auto exitLoop = screen.ExitLoopClosure(); +#if defined(_WIN32) + const auto notifyScreenLoop = [&]() { + { + std::lock_guard lock(screenWakeMutex); + ++screenWakeSerial; + } + screenWakeCondition.notify_one(); + }; +#else + const auto notifyScreenLoop = []() {}; +#endif + const auto queueRedraw = [&]() { + if (running.load() && !redrawQueued.exchange(true)) { + screen.PostEvent(Event::Custom); + notifyScreenLoop(); + } + }; std::thread worker([&]() { try { @@ -2475,6 +2501,7 @@ int runInteractive(std::unique_ptr capture, if (signalRequested != 0) { running.store(false); exitLoop(); + notifyScreenLoop(); break; } const uint64_t requestedOutputListSerial = @@ -2485,9 +2512,7 @@ int runInteractive(std::unique_ptr capture, requestedOutputListSerial, capture->listOutputDevices(), }); - if (running.load() && !redrawQueued.exchange(true)) { - screen.PostEvent(Event::Custom); - } + queueRedraw(); } const OutputSwitchRequest outputSwitchRequest = @@ -2514,9 +2539,7 @@ int runInteractive(std::unique_ptr capture, outcome.started, outcome.error, }); - if (running.load() && !redrawQueued.exchange(true)) { - screen.PostEvent(Event::Custom); - } + queueRedraw(); if (!outcome.captureRunning) { throw std::runtime_error(outcome.error); } @@ -2581,13 +2604,13 @@ int runInteractive(std::unique_ptr capture, : activeStarted.deviceLabel; next.captureOverrun = captureOverrun; frameStore.publish(std::move(next)); - if (running.load() && !redrawQueued.exchange(true)) { - screen.PostEvent(Event::Custom); - } - nextFrameAt = now + + queueRedraw(); + nextFrameAt = advanceFrameDeadline( + nextFrameAt, + now, displayFrameInterval(effectiveRefreshRate( appliedSettings.refreshRate, - appliedSettings.terminalCompatibility)); + appliedSettings.terminalCompatibility))); } std::this_thread::sleep_for(kCapturePollInterval); } @@ -2595,6 +2618,7 @@ int runInteractive(std::unique_ptr capture, workerError = std::current_exception(); if (running.exchange(false)) { exitLoop(); + notifyScreenLoop(); } } }); @@ -3528,7 +3552,26 @@ int runInteractive(std::unique_ptr capture, std::exception_ptr screenError; try { +#if defined(_WIN32) + // FTXUI 7's blocking loop enforces its 60 FPS ceiling with sleep_for. + // The default Windows timer resolution rounds that sleep to about 31 ms, + // capping redraws near 32 FPS. The capture worker already paces and + // coalesces redraws, so park this loop until that worker queues one. + Loop loop(&screen, component); + uint64_t handledWakeSerial = 0; + while (!loop.HasQuitted()) { + loop.RunOnce(); + if (loop.HasQuitted()) break; + + std::unique_lock lock(screenWakeMutex); + screenWakeCondition.wait(lock, [&]() { + return screenWakeSerial != handledWakeSerial || !running.load(); + }); + handledWakeSerial = screenWakeSerial; + } +#else screen.Loop(component); +#endif } catch (...) { screenError = std::current_exception(); } diff --git a/tui/test/tui_tests.cpp b/tui/test/tui_tests.cpp index 88503f2..56c5f3d 100644 --- a/tui/test/tui_tests.cpp +++ b/tui/test/tui_tests.cpp @@ -2,6 +2,7 @@ #include "cli.h" #include "dashboard_layout.h" #include "display_model.h" +#include "frame_pacing.h" #include "frame_rate_meter.h" #include "meter_display_model.h" #include "output_selection.h" @@ -1119,6 +1120,52 @@ void testFrameRateMeter() { "render telemetry should recover safely from a regressing clock"); } +void testFramePacing() { + using Clock = std::chrono::steady_clock; + using namespace std::chrono_literals; + + const Clock::time_point origin{}; + const auto interval = 16ms; + require(Prism::Tui::advanceFrameDeadline(origin, origin, interval) == + origin + interval, + "an on-time frame should preserve the original cadence"); + require(Prism::Tui::advanceFrameDeadline(origin, origin + 1ms, interval) == + origin + interval, + "a slightly late frame should not shift the original cadence"); + require(Prism::Tui::advanceFrameDeadline(origin, origin + 51ms, interval) == + origin + 64ms, + "a substantially late frame should skip every missed deadline"); + + const auto afterLongPause = Prism::Tui::advanceFrameDeadline( + origin, origin + 24h, interval); + require(afterLongPause > origin + 24h && + afterLongPause <= origin + 24h + interval, + "a long pause should advance directly to the first future deadline"); + + const auto simulateCoarseWindowsTimer = [&](int framesPerSecond) { + const auto frameInterval = std::chrono::microseconds( + 1000000 / framesPerSecond); + auto deadline = origin; + size_t frames = 0; + for (auto wakeAt = origin; + wakeAt < origin + 1s; + wakeAt += 15625us) { + if (wakeAt < deadline) continue; + ++frames; + deadline = Prism::Tui::advanceFrameDeadline( + deadline, wakeAt, frameInterval); + } + return frames; + }; + + require(simulateCoarseWindowsTimer(30) == 30, + "coarse Windows wakeups should preserve a 30 FPS cadence"); + require(simulateCoarseWindowsTimer(60) == 60, + "coarse Windows wakeups should preserve a 60 FPS cadence"); + require(simulateCoarseWindowsTimer(120) == 64, + "120 FPS should remain bounded by the available coarse wakeups"); +} + } // namespace int main() { @@ -1134,6 +1181,7 @@ int main() { testPitchReadoutResponse(); testScrollingHistory(); testPipelineAndFakeCapture(); + testFramePacing(); testFrameRateMeter(); testThreadSafeSnapshots(); std::cout << "Prism TUI tests passed\n";