Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add facilities for signal throttling and debouncing #45

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions src/kdbindings/signal.h
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,46 @@ class Signal
return m_connections.insert({ slot });
}

// Connect a callback function with Throttling
Private::GenerationalIndex connectWithThrottling(std::function<void(Args...)> const &slot, int interval)
{
std::chrono::milliseconds throttleDelay(interval);
auto lastCallTime = std::chrono::high_resolution_clock::now() - throttleDelay; // Initialize so it can be triggered immediately the first time.

auto throttleCallBack = [slot = std::move(slot), throttleDelay, lastCallTime](Args... args) mutable {
auto now = std::chrono::high_resolution_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - lastCallTime);

if (elapsed.count() >= throttleDelay.count()) {
slot(args...);
lastCallTime = now;
}
};

return m_connections.insert({ throttleCallBack });
}

// Connect a callback function with Debouncing
Private::GenerationalIndex connectWithDebouncing(std::function<void(Args...)> const &slot, int interval)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I read the description of throttler and debouncer correctly, this here isn't actually a Debouncer, but a trailing Throttler.

If a 100ms Debouncer is called 20 times, every time with a delay of 50ms in-between it should only execute the slot once - 100ms after the last of the twenty signals is received.
The current implementation would call the slot 10 times, after every second signal, where the timeout of 100ms expires each time.
That's what a throttler should do if my understanding is correct.

Please adjust the tests to differentiate between debouncer and throttler.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implementation note:
I believe in our current implementation we cannot easily implement a debouncer (or potentially even a trailing throttler).
As a debouncer needs to emit a signal after the timeout expires. However, it cannot do that, as it's not in control of the currently running thread.
The implementation in KDToolBox uses QTimer to achieve this.

However, QTimer relies on Qt's event-loop to be able to run on the currently active thread:

In multithreaded applications, you can use QTimer in any thread that has an event loop. To start an event loop from a non-GUI thread, use QThread::exec(). Qt uses the timer's thread affinity to determine which thread will emit the timeout() signal. Because of this, you must start and stop the timer in its thread; it is not possible to start a timer from another thread.

As we don't have an event-loop to queue even events on we use ConnectionEvaluator as a substitute. So any Debouncer would need a ConnectionEvaluator to which it could queue the emissions.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah indeed it is challenging to implement debouncer and throttler in our current implementation , so basically if i am understanding clearly, we need a way that whenever a signal is received, we schedule a task to run after the debounce interval. If another signal is received before the interval expires, we cancel/remove the previously scheduled task and schedule a new one. And something similar with throttler ? We will need a mechanism to queue up the task/slots for debouncer and throttler, or we may use ConnectionEvaluator but need to look once the patch for it lands.

{
std::chrono::milliseconds debounceDelay(interval);
auto lastEventTime = std::chrono::high_resolution_clock::now();
auto lastCallTime = lastEventTime - debounceDelay; // Initialize so it can be triggered immediately the first time.

auto debounceCallBack = [slot = std::move(slot), debounceDelay, lastEventTime, lastCallTime](Args... args) mutable {
auto now = std::chrono::high_resolution_clock::now();
lastEventTime = now;

auto timeSinceLastCall = std::chrono::duration_cast<std::chrono::milliseconds>(now - lastCallTime);
if (timeSinceLastCall.count() >= debounceDelay.count()) {
slot(args...);
lastCallTime = now;
}
};

return m_connections.insert({ debounceCallBack });
}

// Disconnects a previously connected function
void disconnect(const Private::GenerationalIndex &id) override
{
Expand Down
42 changes: 42 additions & 0 deletions tests/signal/tst_signal.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,48 @@ TEST_CASE("Signal connections")
REQUIRE(lambdaCalled == true);
}

SUBCASE("Test connectWithThrottling")
{
Signal<int> signal;

int count = 0;
auto handle = signal.connectWithThrottling([&count](int value) {
count++;
},
100);

signal.emit(2);
REQUIRE(count == 1); // First emission should trigger the slot immediately

std::this_thread::sleep_for(std::chrono::milliseconds(50)); // Within the throttling interval
signal.emit(2);
REQUIRE(count == 1); // Second emission shouldn't trigger the slot due to throttling

std::this_thread::sleep_for(std::chrono::milliseconds(100)); // After the throttling interval
signal.emit(2);
REQUIRE(count == 2); // Third emission should trigger the slot
}
SUBCASE("Test connectWithDebouncing")
{
Signal<int> signal;
int count = 0;
auto handle = signal.connectWithDebouncing([&count](int value) {
count++;
},
100);

signal.emit(1);
REQUIRE(count == 1); // Debouncing interval hasn't passed

std::this_thread::sleep_for(std::chrono::milliseconds(50)); // Still within the debouncing interval
signal.emit(2);
REQUIRE(count == 1); // Debouncing interval still hasn't passed

std::this_thread::sleep_for(std::chrono::milliseconds(50)); // After the debouncing interval
signal.emit(2);
REQUIRE(count == 2);
}

SUBCASE("A signal with arguments can be connected to a lambda and invoked with const l-value args")
{
Signal<std::string, int> signal;
Expand Down