TimelineEvents | A Coroutine-Based Event System

Showcase your libraries, tools and other projects that help your fellow love users.
User avatar
babulous
Prole
Posts: 9
Joined: Sun Nov 03, 2019 5:50 pm

Re: TimelineEvents | A Coroutine-Based Event System

Post by babulous »

yetneverdone wrote: Mon Nov 04, 2019 11:19 pm The example seems complex, could be simplified i guess with more explanation?
I edited the example to include some comments for clarification.
pgimeno wrote: Tue Nov 05, 2019 11:30 pm I've just run into one. I'm writing a very straightforward input function that lets you enter characters and delete them. In your example, you create a branch in order to read TextInput, but to me it would be much clearer if I could poll both TextInput and KeyPress with a non-blocking function, in a loop where I would also call E.Step(). As things are, I have to repeat the display code both for the deletion and for the typing branches.
it's cases like these why i created certain events like E.PollMouseActivity. An E.PollKeyboardActivity could probably be useful for something simple like that. I've done some testing with text boxes that have more functionality and my solution always tends to be more branching. Here's my example from GitHub modified, admittedly it gets quite a bit more complex, but can easily be scaled up to include more functionality like; cursor movement, delete key, copy, paste, all without increasing branching complexity much further.

Code: Select all

local E = require "tlevent"

io.output():setvbuf("no")

E.O.Attach()
E.O.Do(function()
  print("Hello!")
  E.Wait(1)
  print("What's your name?")
  E.Wait(1)
  local name = ""
  local enter_name = E.Branch(function() -- wrapping branch
    local old_name = ""
    E.Branch("loop", function() -- display
      print("Enter your name: " .. name)
      old_name = name
      repeat E.Step() until name ~= old_name
    end)
    E.Branch("loop", function() -- typing
      local text = E.PollTextInput()
      name = name .. text
    end)
    E.Branch("loop", function() -- deleting
      E.PollKeyPress("backspace")
      name = name:sub(1, -2)
    end)
  end)
  E.PollKeyPress("return")
  E.G.Kill(enter_name) -- will kill all the child branches too
  print("")
  E.Wait(1)
  print("Hello " .. name .. ", nice to meet you!")
  E.Wait(3)
  print("[Press any key to exit]")
  E.PollKeyPress()
  love.event.push("quit")
end)
Also, I think I just realized what you're meaning when you say "non-blocking events." There are undocumented functions for "Passive" events. It's used internally for E.PollMouseActivity. I didn't document them because most scenarios can also be done with branching, but also I planned on changing how passives work by allowing the creation of timelines with E() inside of timelines instead of using E.Branch. This allows them to be manually updated automatically inside of the timeline. Although I've decided to give the whole thing an overhaul so I guess that doesn't matter much now. In the new version I already have it working to create non branching timelines inside of other timelines.

Here's the source for E.PollMouseActivity.

Code: Select all

function E.PollMouseActivity()
  local pmm = E.Passive(E.PollMouseMove)
  local pmp = E.Passive(E.PollMousePress)
  local pmr = E.Passive(E.PollMouseRelease)
  local pmw = E.Passive(E.PollMouseWheel)
  while true do
    E.Step()
    E.PassiveStep(pmm, pmp, pmr, pmw)
    if E.IsPassiveDone(pmm) then return "MouseMoved",    E.GetPassiveResults(pmm) end
    if E.IsPassiveDone(pmp) then return "MousePressed",  E.GetPassiveResults(pmp) end
    if E.IsPassiveDone(pmr) then return "MouseReleased", E.GetPassiveResults(pmr) end
    if E.IsPassiveDone(pmw) then return "MouseWheel",    E.GetPassiveResults(pmw) end
  end
end
Here's an equivalent in the new version.

Code: Select all

function TL.Event.MouseActivity()
  local pmm = TL(TL.Event.MouseMoved)
  local pmp = TL(TL.Event.MousePressed)
  local pmr = TL(TL.Event.MouseReleased)
  local pmw = TL(TL.Event.WheelMoved)
  while true do
    TL.Event.Step()
    pmm:Step(); if pmm:IsDone() then return "MouseMoved",    pmm:GetResults() end
    pmp:Step(); if pmp:IsDone() then return "MousePressed",  pmp:GetResults() end
    pmr:Step(); if pmr:IsDone() then return "MouseReleased", pmr:GetResults() end
    pmw:Step(); if pmw:IsDone() then return "WheelMoved",    pmw:GetResults() end
  end
end
This isn't possible in the current version because of some old optimizations in place that prevent garbage collection of timelines unless they explicitly die or are killed. But in the new one they'll clear up even if they just fall out of scope.
User avatar
pgimeno
Party member
Posts: 3674
Joined: Sun Oct 18, 2015 2:58 pm

Re: TimelineEvents | A Coroutine-Based Event System

Post by pgimeno »

To be clear, what I meant by non-blocking is similar to the difference between Channel:demand (blocking) and Channel:pop (non-blocking). The former waits until there's a message; the latter returns nil if there are no messages.

At first I figured I could poll a non-blocking TextInput first, and if there was no text (it returned nil), then poll a non-blocking KeyPressed in order to know if Return or Backspace was pressed. But getting the logic behind that right, is more complicated than it seems, when order is taken into account and multiple keys are allowed per frame. You could, for example, get a keypress for a control key after a textinput key, and think it was not pressed in the textinput event, when in fact it was.

One idea would be to return both keypressed and textinput with the same poll, but it doesn't seem easy. Sadly, keypressed comes before textinput; if it was the other way around, you could store the textinput without passing it down, and on keypressed, send both keypressed and textinput (or nil or false if the key did not generate textinput). Maybe a hack can be done by peeking the LÖVE event queue on keypressed, to see if the next event is the textinput event generated by this key press. Dirty as hell, but that's what we have. And complicated by the fact that on Android, according to my tests, keyreleased comes before textinput too.

With the version still on GitHub, and using the hack I explained in an earlier post, this is a simplified version of how I implemented the input for my template; note drawing is persistent between frames:

Code: Select all

  local str = ''
  repeat
    erase_str_background(x, y, maxlen + 1)
    love.graphics.print(str .. "_", x, y)
    local k = TL.PollTextInput()
    if k == '\b' and str ~= '' then
      str = str:sub(1, -2)
    elseif k >= '0' and k <= '9' then
      str = str .. k
      if #str > maxlen then
        str = str:sub(1, maxlen)
      end
    end
  until k == '\r' or k == '\n'
  return str
That's clearer to me than using multiple timelines.
User avatar
babulous
Prole
Posts: 9
Joined: Sun Nov 03, 2019 5:50 pm

Re: TimelineEvents | A Coroutine-Based Event System

Post by babulous »

Okay, I made the new version, I'm happier with this design and it contains some new features over the old one. I really just sat on releasing it because I was uncertain about a few things with the API, but it's fine.
pgimeno wrote: Wed Nov 06, 2019 2:13 pm To be clear, what I meant by non-blocking is similar to the difference between Channel:demand (blocking) and Channel:pop (non-blocking). The former waits until there's a message; the latter returns nil if there are no messages.
Sorry for the VERY late reply, I think I understand. The new version contains a function TL.Peek(trigger). A trigger is a specific type of event that waits for certain conditions to happen before returning a value, basically the difference is events may update the program state and triggers do not. Anyway, what TL.Peek does is it will look at a trigger without blocking and simply return whether or not the trigger was triggered or not this frame and if so, return whatever results it might have.

Example

Code: Select all

function love.update(dt)
  if TL.Peek(TL.Trigger.OnKeyPress, "escape") then
    love.event.quit()
  end
end
User avatar
pgimeno
Party member
Posts: 3674
Joined: Sun Oct 18, 2015 2:58 pm

Re: TimelineEvents | A Coroutine-Based Event System

Post by pgimeno »

I found a typo in https://github.com/babulous/TimelineEve ... umentation: "TL.mousereleased" is listed twice, I think one of them should be "TL.mousemoved".

I'm still working out the details of the new version and having some trouble. It seems I have to call Step() myself when I'm in a loop that calls a trigger? Or am I misusing it? I'm starting it with TL.Do(). Before, whatever blocking function I called did the Step() itself and I didn't have to worry about that. I understand I have to call it if I'm using the peek function.

I'm also getting an error when calling TL.Peek(TL.Trigger.TextInput):

Code: Select all

timeline/init.lua:254: attempt to call field 'DidPeekFinish' (a nil value)
I can work out a small reproducible example if that will help.

I'm hooking the love events to the TL events manually like this: love.mousepressed = TL.mousepressed Is that OK?
User avatar
babulous
Prole
Posts: 9
Joined: Sun Nov 03, 2019 5:50 pm

Re: TimelineEvents | A Coroutine-Based Event System

Post by babulous »

pgimeno wrote: Tue Feb 04, 2020 4:43 pm I found a typo in https://github.com/babulous/TimelineEve ... umentation: "TL.mousereleased" is listed twice, I think one of them should be "TL.mousemoved".
You're right, oops ><"
pgimeno wrote: Tue Feb 04, 2020 4:43 pm I'm still working out the details of the new version and having some trouble. It seems I have to call Step() myself when I'm in a loop that calls a trigger? Or am I misusing it? I'm starting it with TL.Do(). Before, whatever blocking function I called did the Step() itself and I didn't have to worry about that. I understand I have to call it if I'm using the peek function.
So there's 2 different step functions, the timeline:Step() function in the timeline object, which is the update function, and the TL.Step() event. So you call timeline:Step() in your main loop for each of your timeline objects and TL.Step() inside of your timelines. "Step" just means update. And you don't need to call step when you use the peek function, peek simply takes a trigger function and tells you whether or not it triggered on this frame or not. The TL.Step() function is not meant to be a global update function, there is no global update function other than TL.update(dt) which is unrelated, you call timeline:Step() manually for every timeline object you create.

TL.Peek(trigger, [args...]) is a function that you can call anytime, anywhere, there's no preconditions or setup or anything, literally just call the function and pass in the trigger you want to check out. It just works. It's not exactly the non-blocking kind of function that I think you're thinking of. It specifically takes triggers; not events, not timelines. This is because triggers are written in a particular way that allows it to work.
pgimeno wrote: Tue Feb 04, 2020 4:43 pm I'm also getting an error when calling TL.Peek(TL.Trigger.TextInput):

Code: Select all

timeline/init.lua:254: attempt to call field 'DidPeekFinish' (a nil value)
I can work out a small reproducible example if that will help.
Ok, I goofed kinda hard on this one, there was a typo in the code when I renamed some functions before release, DidPeekFinish isn't a real function, it's actually called DidPeekTrigger. I fixed it just now.
pgimeno wrote: Tue Feb 04, 2020 4:43 pm I'm hooking the love events to the TL events manually like this: love.mousepressed = TL.mousepressed Is that OK?
It's not not okay, it'll work, but you limit yourself by preventing your ability to override love.mousepressed for your own purposes. Ideally you'd either call TL.Attach() which will automatically preserve your own callbacks, or call TL.mousepressed manually inside of your own love.mousepressed function.

Just about everything in the library is designed to be manual, nothing is automatic, with the exception of TL.Attach() which automatically hooks into LOVE's events.
User avatar
pgimeno
Party member
Posts: 3674
Joined: Sun Oct 18, 2015 2:58 pm

Re: TimelineEvents | A Coroutine-Based Event System

Post by pgimeno »

Thanks for the quick fix. I'll try it later on.

Okay, here's an example of how I used the previous version of the library:

Code: Select all

local TLE = require 'tlevent'
local TLO = TLE.O


local canvas


local function script()
  -- Input function that allows entering any text:
  local function simpleInput(x, y, maxlen)
    local font = love.graphics.getFont()
    local H = font:getHeight()
    local W = font:getWidth("M")
    local str = ''
    repeat

      -- Erase background
      love.graphics.push("all")
      love.graphics.setColor(love.graphics.getBackgroundColor())
      love.graphics.rectangle("fill", x, y, (maxlen + 1) * W, H)
      love.graphics.pop()

      -- Print string and cursor
      love.graphics.print(str .. "_", x, y)

      -- Get key
      local k = TLE.PollTextInput()

      if k == '\b' and str ~= '' then
        str = str:sub(1, -2)
      elseif k >= '0' and k <= '9' then
        str = str .. k
      end
      if #str > maxlen then
        str = str:sub(1, maxlen)
      end
    until k == '\r'
    return str
  end

  love.graphics.print("Enter N: ")
  local N = simpleInput(love.graphics.getFont():getWidth("Enter N: "), 0, 5)
  love.graphics.print("N = "..N, 0, 50)
  TLE.PollTextInput()
  love.event.quit()
end


function love.load()
  canvas = love.graphics.newCanvas()
  love.graphics.setCanvas(canvas)
  love.graphics.clear(0, 0, 0, 255)
  TLO.Do(script)
  love.graphics.setCanvas()
  love.graphics.push()
end


function love.update(dt)
  love.graphics.setCanvas(canvas)
  love.graphics.pop()
  TLO.update(dt)
  love.graphics.push()
  love.graphics.setCanvas()
end


function love.draw()
  local r,g,b,a = love.graphics.getColor()
  local rgbmode, alphamode = love.graphics.getBlendMode()
  love.graphics.setColor(255, 255, 255, 255)
  love.graphics.setBlendMode("replace", "premultiplied")
  love.graphics.draw(canvas)
  love.graphics.setBlendMode(rgbmode, alphamode)
  love.graphics.setColor(r, g, b, a)
end


function love.quit()
  love.graphics.pop()
end


local keymap = {backspace = '\b', ["return"] = '\r', kpenter = '\r'}
function love.keypressed(k, ...)
  if k == 'escape' then
    return love.event.quit()
  end
  TLO.keypressed(k, ...)
  local ti_map = keymap[k]
  if ti_map then
    TLO.textinput(ti_map)
  end
end

love.textinput = TLO.textinput
The key part here is the repeat...until loop within function script(). The function TLE.PollTextInput blocked the thread (the coroutine) until LÖVE received something in the textinput callback. I used the hack I mentioned earlier, where I fed characters to TL.textinput() from the keypressed event, in order to use one single event for Enter and Backspace without needing extra timelines.

It worked fine. However, I've translated it to the new API (directly, without using Poll(trigger) yet) and when I press a key, a few seconds later I get an "infinite loop detected, try calling TL.Step()" error. Here's the same code adapted to the new API:

Code: Select all

local TL = require 'timeline'


local canvas


local function script()
  -- Input function that allows entering any text:
  local function simpleInput(x, y, maxlen)
    local font = love.graphics.getFont()
    local H = font:getHeight()
    local W = font:getWidth("M")
    local str = ''
    repeat

      -- Erase background
      love.graphics.push("all")
      love.graphics.setColor(love.graphics.getBackgroundColor())
      love.graphics.rectangle("fill", x, y, (maxlen + 1) * W, H)
      love.graphics.pop()

      -- Print string and cursor
      love.graphics.print(str .. "_", x, y)

      -- Get key
      local k = TL.Trigger.TextInput()

      if k == '\b' and str ~= '' then
        str = str:sub(1, -2)
      elseif k >= '0' and k <= '9' then
        str = str .. k
      end
      if #str > maxlen then
        str = str:sub(1, maxlen)
      end
    until k == '\r'
    return str
  end

  love.graphics.print("Enter N: ")
  local N = simpleInput(love.graphics.getFont():getWidth("Enter N: "), 0, 5)
  love.graphics.print("N = "..N, 0, 50)
  TL.Trigger.TextInput()
  love.event.quit()
end


function love.load()
  canvas = love.graphics.newCanvas()
  love.graphics.setCanvas(canvas)
  love.graphics.clear(0, 0, 0, 255)
  TL.Do(script)
  love.graphics.setCanvas()
  love.graphics.push()
end


function love.update(dt)
  love.graphics.setCanvas(canvas)
  love.graphics.pop()
  TL.update(dt)
  love.graphics.push()
  love.graphics.setCanvas()
end


function love.draw()
  local r,g,b,a = love.graphics.getColor()
  local rgbmode, alphamode = love.graphics.getBlendMode()
  love.graphics.setColor(255, 255, 255, 255)
  love.graphics.setBlendMode("replace", "premultiplied")
  love.graphics.draw(canvas)
  love.graphics.setBlendMode(rgbmode, alphamode)
  love.graphics.setColor(r, g, b, a)
end


function love.quit()
  love.graphics.pop()
end


local keymap = {backspace = '\b', ["return"] = '\r', kpenter = '\r'}
function love.keypressed(k, ...)
  if k == 'escape' then
    return love.event.quit()
  end
  TL.keypressed(k, ...)
  local ti_map = keymap[k]
  if ti_map then
    TL.textinput(ti_map)
  end
end

love.textinput = TL.textinput
Is TL.Trigger.TextInput not working as TLE.PollTextInput did before? Am I doing something wrong?
User avatar
babulous
Prole
Posts: 9
Joined: Sun Nov 03, 2019 5:50 pm

Re: TimelineEvents | A Coroutine-Based Event System

Post by babulous »

pgimeno wrote: Tue Feb 04, 2020 10:59 pm The key part here is the repeat...until loop within function script(). The function TLE.PollTextInput blocked the thread (the coroutine) until LÖVE received something in the textinput callback. I used the hack I mentioned earlier, where I fed characters to TL.textinput() from the keypressed event, in order to use one single event for Enter and Backspace without needing extra timelines.

It worked fine. However, I've translated it to the new API (directly, without using Poll(trigger) yet) and when I press a key, a few seconds later I get an "infinite loop detected, try calling TL.Step()" error. Here's the same code adapted to the new API:

Code: Select all

local TL = require 'timeline'


local canvas


local function script()
  -- Input function that allows entering any text:
  local function simpleInput(x, y, maxlen)
    local font = love.graphics.getFont()
    local H = font:getHeight()
    local W = font:getWidth("M")
    local str = ''
    repeat

      -- Erase background
      love.graphics.push("all")
      love.graphics.setColor(love.graphics.getBackgroundColor())
      love.graphics.rectangle("fill", x, y, (maxlen + 1) * W, H)
      love.graphics.pop()

      -- Print string and cursor
      love.graphics.print(str .. "_", x, y)

      -- Get key
      local k = TL.Trigger.TextInput()

      if k == '\b' and str ~= '' then
        str = str:sub(1, -2)
      elseif k >= '0' and k <= '9' then
        str = str .. k
      end
      if #str > maxlen then
        str = str:sub(1, maxlen)
      end
    until k == '\r'
    return str
  end

  love.graphics.print("Enter N: ")
  local N = simpleInput(love.graphics.getFont():getWidth("Enter N: "), 0, 5)
  love.graphics.print("N = "..N, 0, 50)
  TL.Trigger.TextInput()
  love.event.quit()
end


function love.load()
  canvas = love.graphics.newCanvas()
  love.graphics.setCanvas(canvas)
  love.graphics.clear(0, 0, 0, 255)
  TL.Do(script)
  love.graphics.setCanvas()
  love.graphics.push()
end


function love.update(dt)
  love.graphics.setCanvas(canvas)
  love.graphics.pop()
  TL.update(dt)
  love.graphics.push()
  love.graphics.setCanvas()
end


function love.draw()
  local r,g,b,a = love.graphics.getColor()
  local rgbmode, alphamode = love.graphics.getBlendMode()
  love.graphics.setColor(255, 255, 255, 255)
  love.graphics.setBlendMode("replace", "premultiplied")
  love.graphics.draw(canvas)
  love.graphics.setBlendMode(rgbmode, alphamode)
  love.graphics.setColor(r, g, b, a)
end


function love.quit()
  love.graphics.pop()
end


local keymap = {backspace = '\b', ["return"] = '\r', kpenter = '\r'}
function love.keypressed(k, ...)
  if k == 'escape' then
    return love.event.quit()
  end
  TL.keypressed(k, ...)
  local ti_map = keymap[k]
  if ti_map then
    TL.textinput(ti_map)
  end
end

love.textinput = TL.textinput
Is TL.Trigger.TextInput not working as TLE.PollTextInput did before? Am I doing something wrong?
Yes, so triggers are slightly different in the new version. Before, functions like TLE.PollTextInput would block immediately before checking it's trigger conditions. This means that if there was text input on the first frame that you call TLE.PollTextInput it would be ignored. That's a problem for real time input, so triggers in the new version don't do that, but as a consequence triggers can cause infinite loops if you don't make a call to TL.Step() AFTER you make use of the trigger after it activates. I knew that this would be a common mistake (i make it a lot, too) so I made it so triggers can check to see if there's an infinite loop and return an error instead of freezing the program, so that's why you got that error message.

So I added TL.Step() to the bottom of your repeat until loop and tested, it should work now.

Code: Select all

  -- Input function that allows entering any text:
  local function simpleInput(x, y, maxlen)
    local font = love.graphics.getFont()
    local H = font:getHeight()
    local W = font:getWidth("M")
    local str = ''
    repeat

      -- Erase background
      love.graphics.push("all")
      love.graphics.setColor(love.graphics.getBackgroundColor())
      love.graphics.rectangle("fill", x, y, (maxlen + 1) * W, H)
      love.graphics.pop()

      -- Print string and cursor
      love.graphics.print(str .. "_", x, y)

      -- Get key
      local k = TL.Trigger.TextInput()

      if k == '\b' and str ~= '' then
        str = str:sub(1, -2)
      elseif k >= '0' and k <= '9' then
        str = str .. k
      end
      if #str > maxlen then
        str = str:sub(1, maxlen)
      end
      TL.Step() -- step after trigger to prevent infinite loop
    until k == '\r'
    return str
  end
User avatar
pgimeno
Party member
Posts: 3674
Joined: Sun Oct 18, 2015 2:58 pm

Re: TimelineEvents | A Coroutine-Based Event System

Post by pgimeno »

Oh ok, I wasn't aware of that difference. I'm struggling to understand what's going on. Why does it block the first time and not the second?

I'm trying to come up with some way to make Step() be called automatically when I want a blocking trigger. For example, Not sure why, but the trigger is returning the same value over and over when called without stepping. I'd like to have a function, e.g. TL.Wait(trigger), that instead of returning the same value as the last time, detects that it's been called without stepping and automatically steps. It's what attracted me the most of the idea, to have the functions transparently block when there wasn't any input, while the rest of the program was still running. Having to add manual yields makes it unsuitable for my use case (a template that lets users write programs the "classic" way).
User avatar
babulous
Prole
Posts: 9
Joined: Sun Nov 03, 2019 5:50 pm

Re: TimelineEvents | A Coroutine-Based Event System

Post by babulous »

Ah no, the old version was the one that would block immediately when called, but the new version only blocks if there the trigger didn't activate that frame. So if the trigger activated the same frame it's called it just passes through without blocking and returns whatever those results were.

In this specific case this means if you calling TL.Trigger.TextInput and love.textinput happen on the same frame it just passes through without blocking and returns the arguments of love.textinput. So if it activated and there's a loop then there's no step to stop it from looping infinitely this frame. When you call TL.Step after using the trigger you're just saying "ok, i'm done, continue to the next frame now." So when you TL.Step you step out of the frame where the trigger activated, then the next frame your loop will call TL.Trigger.TextInput and start over again. So stepping is necessary.

For your case, you could write some kind of "trigger event" that simply steps first then calls the trigger you want, the problem from before won't affect your situation.

Code: Select all

function MyTextInputEvent()
  TL.Step()
  return TL.Trigger.TextInput()
end
This is how old-style "triggers" worked, and for your situation it should be fine. Note that this would be an event and not a trigger anymore, meaning TL.Peek won't work on MyTextInputEvent.
Post Reply

Who is online

Users browsing this forum: No registered users and 3 guests