KTextEditor: prevent accidental zooming
ClosedPublic

Authored by rjvbb on Mar 13 2017, 5:27 PM.

Details

Reviewers
None
Group Reviewers
KTextEditor
Commits
R39:f51befdc80b1: prevent accidental zooming.
Summary

BUG: 377562

KTextEditor::KateViewInternal::wheelEvent() implements text zoom in reaction to Ctrl+Wheel events, something Qt also implements or used to implement for QTextEdit and/or QTextBrowser.
Problems can occur with systems with a trackpad that use 2-finger scrolling and scroll inertia. These are subject to unexpected, accidental text zooming when the user holds presses the Ctrl key to trigger a shortcut (say, Ctrl-S to save the current document) before scroll coasting has stopped completely.

The end of coasting isn't always clear: scroll events may continue to come in with sub-threshold deltas, or when the view has already reached its extreme position and can scroll no further. This affects me much more frequently than I would like in applications like KDevelop and is especially annoying since there is no standard shortcut to return to 100% (no zoom).

Qt itself has a form of protection against this kind of annoyance on Mac and possibly other systems where inertial scrolling has better platform support than under X11.
This patch provides what I hope is a reasonably minimal implementation of a similar protection that ignores Control modifier changes that occur within a certain lapse of time after the start of a series of wheel events.

Test Plan

This is stripped-down version of a proof of concept designed by Thomas Lübking and me and well tested on Linux and Mac:

https://github.com/RJVB/qtwheeltest/blob/master/wheeltest.cpp

the timing parameters have been tuned in a rather ad-hoc fashion; the lower threshold is probably just fine as it is but the higher threshold could possibly be set lower. It represents the time one would have to hold down the Ctrl key before wheel events again cause zooming after the protection has triggered. Any wheel event without the modifier key set will also reset the timer.

This is a patch that merits being tested in practice, not just reviewed in source code.

Diff Detail

Repository
R39 KTextEditor
Lint
Automatic diff as part of commit; lint not applicable.
Unit
Automatic diff as part of commit; unit tests not applicable.
rjvbb created this revision.Mar 13 2017, 5:27 PM
Restricted Application added a project: Frameworks. · View Herald TranscriptMar 13 2017, 5:27 PM
Restricted Application added subscribers: Frameworks, kwrite-devel. · View Herald Transcript

Thinking aloud: Can you factor this out into a small helper class called WheelEventFilter or similar? I can imagine this would be cleaner.
The code possibly would shrink down to something like:

if (m_wheelEventFilter.processEvent(event)) {
    // ... zoom in / out
    return;
}
src/view/kateviewinternal.cpp
3358

const qint64 ...

3388

Note: instead of NB (had to look it up)

rjvbb updated this revision to Diff 12448.Mar 14 2017, 12:27 AM
rjvbb edited the test plan for this revision. (Show Details)

Was this what you had in mind?

rjvbb marked 2 inline comments as done.Mar 14 2017, 12:28 AM
anthonyfieroni added inline comments.
src/view/kateviewinternal.cpp
76

About me, it's designed to unset modifiers so param looks unwanted.

100

modeState &= ~modifier;

rjvbb marked an inline comment as done.Mar 14 2017, 11:26 AM
rjvbb added inline comments.
src/view/kateviewinternal.cpp
76

I don't disagree but not for exactly the same reason. I'm not sure the function is or should be *designed* for that task. My first reaction to the suggestion of creating a helper class was that it would have this somewhat unexpected side-effect. That's part of why I added a parameter.

Now the real question is of course if there would ever be use for those parameters. I personally see more use for accelerated wheel-scrolling than for wheel-zooming but there are probably other ways to trigger it, rather than a subtle timing trick.

The modifier argument is less doubtful in my mind; it could be under control of a user setting, which would probably even be a prerequisite if someone wants to allow accelerated scrolling (also tied to the Control modifier, AFAIK hardwired in Qt).

rjvbb updated this revision to Diff 12466.Mar 14 2017, 1:01 PM

This makes unsetting the modifier non-optional and rewords some comments

Ok, we discard first wheel event cause elapsed timer isn't started, right? I think we can "catch" all events we just make it a bit different, set modifiers in contructor and restart elpased timer on global event override function if desired modifiers presents, so if wheelEvent enters before 200 ms elapsed just ignore modifiers so now we don't discard a wheel event just drops earlers modifiers.

rjvbb added a comment.EditedMar 14 2017, 2:50 PM

Ok, we discard first wheel event cause elapsed timer isn't started, right?

No, it's not discarded; no wheel events are being discarded unless they already were being discarded (= horizontal scroll with dynamic word wrap). What my addition does is turn certain Ctrl+Wheel events into regular wheel events so that the view continues to scroll when you press the modified during a scroll (instead of switching to zooming).

There is no previous event so there's no need to do any checks to see if the modifier should be allowed to trigger zooming. So the first wheelevent just passes through the filter unmodified and if the modifier is pressed it will trigger zooming. Any other approach will probably only make things more difficult. And FWIW it's already semantically doubtful to set m_lastWheelEventUnmodified to false in the WheelEventFilter ctor (there is only a last event after the 1st wheel event has been processed) so putting it in the KateViewInternal ctor makes even less sense.

Similarly, the timer must be specific to wheel events, you wouldn't want mouse movements to interfere for instance. Or at least I think so, I only have trackpads nowadays.

Setting the modifier key in KateViewInternal does make sense but more so when it actually becomes possible to change it. Until that's not the case the compiler can optimise the current implementation a bit better (as far as that's of any importance here).

I think we can "catch" all events we just make it a bit different

I don't think I understand what you mean here. What we don't catch is situations where the events tell the view to continue scrolling in a direction where that's no longer possible. My PoC implementation on github catches those situations but would be overkill (and probably interfere with zooming when the document is at the top or bottom).

As to dropping wheel events: that doesn't happen but even if it did that shouldn't be a big deal if only 1 or a few events are dropped. This kind of event arrive in bursts from an effect-driven user input that isn't transient. IOW, contrary to clicking something, s/he will continue to turn the wheelbutton (or swipe up/down) until satisfied with whatever the desired result. I strongly doubt anyone one would notice if we dropped the 1st wheel event for instance.

rjvbb added a subscriber: luebking.Mar 14 2017, 2:51 PM

Ok, note this situation, hold modifier before first ever wheel event => you call m_lastWheelEvent.elapsed on unstarted timer and function returns true (m_accidentalModifier == false, m_lastWheelEventUnmodified == false)

rjvbb added a comment.Mar 14 2017, 8:46 PM

Ok, note this situation, hold modifier before first ever wheel event => you call m_lastWheelEvent.elapsed on unstarted timer

This is true, and things can indeed be improved a bit there.

and function returns true (m_accidentalModifier == false, m_lastWheelEventUnmodified == false)

Am I missing something? For me that is exactly what the function should return, and the internal state is correct too. This works because the initial values for the 2 boolean state variables are set appropriately. In this case deltaT may be assigned a random value (because ElapsedSince::elapsed() is undefined) but that value won't be used.

I can rewrite the code slightly so that the timer isn't used when not started but it won't make a difference for the end result, it will just add a few lines and an additional runtime check. Will do that ASAP.

I'd say try the patch and see if you can break its (now of after I upload the rewritten version). I just tried generating wheel events with the control modifier set from event 1. I even "primed" the text zoom via the keyboard shortcuts to prevent initial zooming hickups. I don't notice anything weird: the zoom works just as you'd expect.

Let me repeat if this wasn't clear: the purpose of this patch is only to prevent accidental zooming because you push the Control key while a scroll is still in progress. There's no way we can decide if a Control press is accidental when nothing else is going on.

In fact, we don't even know if what I now call an accidental Control press is really accidental. It's a reasonable assumption (I think) and it's still easy enough to trigger wheel-zooming with this protection in place. But it is possible that in a certain number of cases that we now treat as regular scroll events the user actually wanted zooming, not scrolling. I just have to hope that it is easier for those users to adapt to the behaviour with protection than it is for users like me to adapt to not having it :)

rjvbb updated this revision to Diff 12480.Mar 14 2017, 10:06 PM
  • don't use QElapsedTimer::elapsed() when the timer isn't valid. Changes nothing to the behaviour but may make the code a bit less obfuscated.
  • edited a few comments

Yes, this is what I meant. I think the code is now better by using an isolated class.

I just read the code again, and I think I would like to suggest another change: The class name currently is too generic, this makes reasoning quite hard.
That said, I suggest:

  • to rename WheelEventFilter to ZoomEventFilter
  • to rename m_accidentalModifier to m_ignoreZoom
  • remove the "bool skip = false" variable, and instead just check m_ignoreZoom instead

Maybe the naming can be improved even more. I will test this patch, but will not have time until Thursday.

src/view/kateviewinternal.cpp
105

The comment talks about "ControlModifier", but the parameter allows also other modifies.

rjvbb updated this revision to Diff 12608.Mar 19 2017, 9:35 AM

Updated as requested

rjvbb updated this revision to Diff 15727.Jun 22 2017, 8:15 AM

refreshed for git/head (and ¡bump!)

rjvbb set the repository for this revision to R39 KTextEditor.Jun 22 2017, 8:16 AM
kfunk added a subscriber: kfunk.Jun 22 2017, 9:04 AM

A couple of minor issues I spotted while looking briefly over the patch.

src/view/kateviewinternal.cpp
76

Style: Use =

85

Style: Indent off

87

Simplify: else if (...) {?

121

Initialize here

122

Dito

rjvbb marked 6 inline comments as done.Jun 22 2017, 2:34 PM
rjvbb added inline comments.
src/view/kateviewinternal.cpp
87

Why the question mark? Simplification should be perfectly fine here, no?

121

Instead of in the ctor, or in addition to?

The KateViewInternal class doesn't initialise any of its member variables this way.

(http://en.cppreference.com/w/cpp/language/data_members ?)

rjvbb updated this revision to Diff 15747.Jun 22 2017, 2:34 PM
rjvbb marked 2 inline comments as done.

Updated as requested.

rjvbb set the repository for this revision to R39 KTextEditor.Jun 22 2017, 2:35 PM

What are the next steps here?

ngraham edited the summary of this revision. (Show Details)Oct 23 2017, 3:38 AM

Given people seem to have tested this, I would be ok to let it go in now.
I guess we need some "user-test" for it anyways.

This revision was automatically updated to reflect the committed changes.
rjvbb added a comment.Nov 12 2017, 4:38 PM

Thanks.

I've been using this patch myself and never noticed any side-effects. Except when an application shows to be susceptible to accidental zooming ... because it uses another widget class that doesn't have this protection :)