4coder's GUI: Challenges with Scrolling

Allen Webster  —  1 year, 1 month ago [Edited 5 minutes later]
In last week's post I discussed the rough plan for making 4coder GUI customizable. That plan is somewhat theoretical, and the implementation only began a few days ago. But for the most part knowing that the system will be immediate mode basically informs the rest of the design. There may be a few things that would be done differently if I knew I was dealing with hooks up front, I may do a little more to help the user manage their state, rather than just assuming they'll be able to do whatever they want. Even still, I don't think it would be too difficult to make those tweaks. One way or the other the user will declare a GUI every frame based on whatever rule they want, and the behaviors I want to support are the same either way. So all together I think it is the "immediate mode" part that informs everything else.

Starting from there, the thing that has been the biggest difficulty is the scrolling system. "Insofaras", 4coder Linux extraordinaire, sees my commit's and can probably attest to the number of times I have "fixed scrolling bugs" in 4coder. So what makes scrolling hard? Well there are a number of things. For one thing the requirements of the 4coder scrolling rules are a bit more complex than your standard view moving rules. On top of that the main drawback of the immediate mode API are being more tied to a particular order of events and difficulty with things that involve global layout information, like how tall the layout is. Third I probably made it worse on myself than I needed by being a bit sloppy about scrolling initially.

The Scrolling Rule
The 4coder scrolling rule is just a bit more complex than what a "standard scrolling rule" has to look like. To be clear I don't mean the math for actually computing the rendering offset, or the method of smooth scrolling towards a target location. The hard part is actually setting up a rule for correctly setting the target location. In 4coder I want the cursor to always be in view. In some editors, what actually happens is the cursor can go out of view, then when you do anything, the view jumps back to the cursor, and you lose the position you thought you had. The downside to keeping the cursor in view is that if you look around for something then want to go back to where you were, you need an explicit command for "return to previous location" or something like that. I think that is better though, it involves no surprises.

Now here comes the nasty part. To keep the cursor in the view there are two things you have to do. The obvious thing is that "if the cursor moves out of the view, move the view to the cursor". This is the rule things like games are usually based on, where a character is always in view because the view is locked to them. That goes out the window as soon as you also want to be able to do things like use the mouse wheel to move the view around. If I just follow the first rule, as soon as I scroll too far from the cursor the view will get locked and stop moving until I move the cursor down. So the rule you sort of want is "if the cursor moves update the view as necessary, if the view moves update the cursor as necessary". Depending on how it is set up there is also the question "what if both move?". If both move in 4coder, I just arbitrarily break the tie and say the cursor wins, the view moves to it.

The problem get's even worse than this. Not only can the cursor or the view change. The actual context of scrolling can change too. If I am looking at one file, and the I switch to another, I don't want to immediately scroll from some arbitrary position. So you also need a rule for what happens on a context change, and ideally you want to avoid any scrolling from happening when a context change happens.

One way to solve this and be 100% correct is just say that you never set one of these three things without setting the others. That is, you could write some code like this:
set_cursor(View *view, int pos){
    move_cursor(view, pos);

set_scroll(View *view, float pos){
    move_scroll(view, pos);

set_context(View *view, int pos, float scroll_pos){
    move_cursor(view, pos);
    move_scroll(view, scroll_pos);

Obviously if you do this you're spending the price of fixing multiple variables every time something moves, so if some command set's the cursor several times just because the author decided to be a bit sloppy, you're also setting the scrolling several times. That is probably not a big deal.

In the case of 4coder though, it felt to me like there was a second problem. I had already written a lot of code that just directly set the cursor, set scrolling positions, etc. and I thought going in and rewriting all of those commands would be a bad idea. I retrospect probably could have tried this and it would have been okay, but that's not what I did.

What I actually came up with is that there is a section of code where the scroll position and cursor are allowed to be set. Before that section starts I hold the previous values, and not until the end of the frame do I look and see which ones changed, and based on that apply the fixing rule. This is where the "both changed" possibility comes up. I think if that was the whole story, that system would work just fine... but...

Immediate Mode vs Scrolling
The next issue is more general to any scrolling system in an immediate mode API. Specifically, any API where you allow the user to respond to changes in the scrolling position. If you just said that the GUI system manages the scrolling and the user has no method of responding to scrolling events, then the this is probably not such an issue. For my system though, I wanted the user to get notified whenever scrolling was done by the user, so that they could look at the event and respond. This turned out to be a useful idea, because it allowed the same scrolling system to tie to the cursor as I was discussing before, and tie to the file highlighted for arrow navigation in the file list, which sound similar, but being able to handle them on a case by case basis was very handy.

The API for this that I first had in the GUI system looked something like:

if (gui_get_scroll_vars(&gui, scroll_context_id, &view->scroll_vars)){

This means that the response to the scroll event happens with one frame of lag. The basic idea here is every time the GUI is executed, if the last execution recorded a scroll event, the user sees it here.

This introduces a new detail. The GUI has to keep a record of whether a scroll event happened and what the state of the scroll position should be because it has to report that back on the next frame when gui_get_scroll_vars is called. This introduces a whole new basic problem, which is that now your immediate mode API is actually holding onto a little bit of state that it thinks is authoritative, namely where the scrolling position should be.

Again, by itself this would be fine, but it turns out this does not play so well with the back and forth between the cursor and scroll position. In this system the cursor responds to the motion in the view with one frame of lag. Now if the cursor is moved we announce that the scrolling position should change, and in the next frame that will look like a scrolling even. Then the cursor will be fixed back into the scrolling region, if you updated the cursor again this frame any time before the if (gui_get_scroll_vars(...)) that cursor position is lost.

This nasty dependence on the order of events has been the source of many scrolling and cursor positioning bugs in 4coder. One part of the confusion is that the user has this local copy of the scroll variables and if you ever update that without declaring the change, or you ever change the scroll variables in the middle of the frame due to, say a wheel, and don't have a way to tell the user about the change until next frame, suddenly you have two sets of scroll variables that both think they're authoritative and do not agree with one another. Getting into a mess like that is when you know the architectural decisions you've made need to be rethought.

BUT before I get to the rethinking process, there is one other issue with scrolling in an immediate mode API that deserves a mention. That is, you very often want to know what the size of the viewable region is and what the size of the content that is being viewed is, so that you can limit the edges of the scrolling and do the lerping from the slider's position to the scroll position. But so far, I've assumed everything will operate with one frame of lag, which means sometimes, even if you get everything right with scrolling and cursor position, you could still get bitten by the fact that your scroll limits are off and so you end up thinking the max scroll position is 0 and clamping to that, even though you actually had a perfectly valid scroll position. Fixing that is a matter of undoing the frame of lag, but this is very difficult for a number of reasons, and I will get into that some other time.

The Moral: Know Where the Authoritative State Lives
Getting back to the nasty order of events and dealing with the cursor to view back and forth and negotiating the user's desire to easily update the scrolling variables while the API needs to somehow report scrolling events that only it can detect, and with a frame of lag.

It turns out this quagmire actually boiled down to one mistake. I did not really carefully think about who was going to have the authoritative state for the scrolling variables. In the original system the API thinks it is authoritative and the user is always trying to correct it before it does something wrong, but at other times the user has to query the scroll variables to correct the user copy. This is the real curse of a retained mode API. The API tries to hold the authoritative version but the user needs more control and so the user also tries to have an authoritative version. To fix this I either had to go full retained or full immediate on the scrolling system. I had to either completely seal of the scroll variables, so that the user could only ever read them, and give the user no control over scroll rules; or I had to say, the API never holds something that it believes to be an authoritative set of scroll variables ever.

I went the second route, because I was already depending on being able to do a lot of case specific rules, and it will mean more flexibility in the GUI system over all.

This actually turns out to be a no-brainer once I tried it. Anything in the GUI system that wants to manipulate the scroll variables, now takes scroll variables as a parameter, and then returns new scroll variables if it thinks a change should happen. Then the user looks at that change and can use it, or not as they please and stores their own copy until the next GUI call that needs scroll variables. This way, from the API's point of view, it does not ever hold an authoritative copy, it is just holding a local set of scroll variables and it's job is to report what it would do to those variables if it was in charge. It never really stored them though.

So what happens to gui_get_scroll_vars? We still want a way to know that a scroll event happened, but we don't want to continue getting scroll variables from the GUI system. It turns out the user can just write this as a helper themselves now! Since any GUI side call that changes the scrolling will tell the user what the new scroll variables are right there, the user can then have their own flag for "the scrolling is dirty, activate next frame" if they want. They could also eliminate the frame of lag in their response to GUI events. (Note there is still a layout frame of lag, which as I said, I will look at more later).

That's it for this week everyone, as usual thanks for following!
Log in to comment