lovatt.dev

March 14th, 2019
14/03/19

She Came In Through The Bathroom Window

In which we add a Window class, further reducing the amount of work our GUI's core module has to do.


After the work we've already done pulling things out of Core.lua, the hierarchy of our GUI now looks like this:

Main Logic
  ↓
Layers
  ↓
Elements

What if we heard through the grapevine that scripts might, one day, be able to open multiple windows?

That would be so cool.

How would that fit into our current structure?

Uh... it wouldn't?

Would it even be possible?

...no?

Never fear - we can use what we've learned thus far to solve the problem.

Our GUI's core logic is built around the idea that there can only be one window at a time, since that's what Reaper currently lets us do.

Which parts do we need to look at?

  • GUI.Init():

    • Sets up the initial window state.
    • Opens the window.
    • Prompts all of the elements to initialize their buffers (Reaper won't let any graphics stuff happen until a window is open).
  • GUI.Main():

    • Updates the current state - keyboard, mouse, etc.
    • Asks the layers to update themselves.
    • Asks the layers to redraw themselves.
    • Draws to the script window.

So... most of that, I guess.

Now, how on earth can we handle more than one of something all doing the same things? Hmm...

99 Problems But A Class Ain't One

As in The Adventure Of The Layer Class a few posts back, our new Window class will consolidate much of what the GUI is doing. Each Window will have properties like:

x,y,w,h
name
layers    -- We need to keep track of which layers belong in which window
isOpen
isRunning -- It might be useful to "pause" windows occasionally
needsRedraw

Properties don't really do a whole lot, of course, so we'll need some methods as well.

Window:open()

This will take over much of what GUI.Init() currently handles. It might need to be split into separate :init and :open methods later on so that scripts can close and reopen windows, but until multiple windows are a possibility I'm not going to worry about it.

Window:close()

In addition to the obvious, it's likely that scripts will want to be able run their own code when a window is closed - automatically saving data, that sort of thing.

Window:pause(), Window:run()

Hopefully self-explanatory.

Window:update(), Window:updateInputState(), Window:handleWindowEvents()

These will take quite a lot of work from Core.lua. Yay.

Window:redraw()

So will this. Core is actually starting to get pretty small at this point.

Window:sortLayers()

Our update and redraw logic will need to process the layers in z order.

Window:addLayers(), Window:removeLayers()

We need to get them into the Window somehow.

Window:findElementByName()

Since we're adding a step to our hierarchy, we should extend this idea. We'll now have GUI.Val() iterate through all of the windows rather than the layers - another good rule of thumb, in fact. Functions should (ideally) never be aware of anything more than one level of abstraction away so as to limit the number of things depend on each other. For us, this means that the GUI should have minimal access to the layers and elements without using Window methods.

Temporary Psychotic State

As straightforward as this all sounds, it actually took quite a bit of work to rewrite the state logic. Not because of being coupled too tightly to the GUI, although it was - no, because it was messy as hell.

We've already dealt with a number of state variables that are just living as top-level GUI.this_thing in the current version, but GUI.Update() uses a lot of them for tracking changes based on the previous loop's state - for instance:

-- Is the left button down?
if GUI.mouse.cap&1==1 then

  -- If it wasn't down last time, we do the :onmousedown logic
  if not GUI.mouse.last_down then
  ...

  -- It was down last time. Has the mouse moved? i.e. the user is dragging
  elseif (GUI.mouse.x ~= GUI.mouse.lx) or (GUI.mouse.y ~= GUI.mouse.ly) then
  ...

...

-- If it was originally clicked in this element and has been released
elseif GUI.mouse.last_down and GUI.mouse_down_elm.name == elm.name then

  GUI.mouse_down_elm = nil

  if not GUI.mouse.dbl_clicked then elm:onmouseup() end

  GUI.elm_updated = true
  GUI.mouse.down = false
  GUI.mouse.dbl_clicked = false
  GUI.mouse.ox, GUI.mouse.oy = -1, -1
  GUI.mouse.off_x, GUI.mouse.off_y = -1, -1
  GUI.mouse.lx, GUI.mouse.ly = -1, -1
  GUI.mouse.downtime = reaper.time_precise()

end

*throws up in mouth*

The worst part? All of that (and more) is running once for every single element - redundant, inefficient, disgusting. Excuse me while I go lock myself in the cellar and flagellate myself as penance.

Ideally the process should look more like this:

  • Have the window update the input state. This could arguably be done by the GUI itself once we have multiple windows to work with, but for now we'll do it this way.
  • Handle any window-level events (if it was closed or resized)
  • Update the layers/elements, passing in the input state
  • Redraw whatever needs redrawing

The easiest way to pass our input state to the elements is as an object:

local state = T{}

state.mouse = { ... }
state.kb = { ... }

state.cur_w = gfx.w
state.cur_h = gfx.h
...

With a few changes to Element:Update(), we can have it refer to a given state argument rather than the GUI.

What about all of the things from the previous state that it needs, though? Well, since the state is an object we can easily hold onto it for the next loop and pass that in as well:

function Window:updateInputState()
  local last = self.state
  local state = T{}

  state.mouse = { ... }
  ...etc...

  self.state = state
  self.last_state = last
end

By adding another argument, last, to Element:Update(), we limit our state logic to one place. On top of fixing the redundancy and inefficiency mentioned above, reducing the number of things that can directly affect our script's state also reduces the number of things that can possibly go wrong.

Functional programming strikes again!