GUI programming

Revision 1, 25.10.07: The UDP server did not work properly if the client is behind a router, this has been fixed now

The previous tutorial (the udp server) lacked an visual input/output facility. GUI programming in Luxinia is fairly simple and we want now to extend our previous UDP example with a GUI that allows us to connect to a server and open multiple clients at the same time, showing a moveable frame for each client. As we donnot want to to mix here the network code with our GUI code, we prepare a file named udp.lua and call it using dofile. We changed the signature of our functions to accept some arguments which are callbackfunctions.

Step 1: A mainmenu

We'd like to have a main menu which allows us to start a server or to start a client. It should be possible to start multiple clients but to start only one server. Thus, if the Server is running we will either disable the start server button or replace it with some gui, or replace it with a "stop server" button.

So let's create the mainframe and add two buttons:

mainframe = TitleFrame:new(0,0,180,80,nil,"Main Menu")
mainframe.startserver = mainframe:add(
  Button:new(5,25,170,25,"Start server"))
mainframe.startclient = mainframe:add(
  Button:new(5,50,170,25,"Start client"))

Container.getRootContainer():add(mainframe)

The result looks like this:

We want now to make the frame dragable if it is dragged in the upper area:

function mainframe:mousePressed(me)
  if me.y<25 then
    self:lockMouse()
    self.mouselockpos = {me.x,me.y}
  end
end

function mainframe:mouseReleased(me)
  if self:isMouseLocker() then self:unlockMouse() end
end

function mainframe:mouseMoved(me)
  if self:isMouseLocker() then
      local x,y = self:getLocation()
      local dx,dy = 
          me.x-self.mouselockpos[1], 
          me.y-self.mouselockpos[2]
      self:setLocation(x+dx,y+dy)
  end
end

If the mouse is now pressed in the title of the window, we can move the window around. Locking the mouse to the component will cause that this component is the only one that receives mouseinputs. That is important because it is not unlikely that the mouse might be dragged outside the window while dragging and we would lose the mouseevents then (which are generated for other components).

Step 2: Starting the server

Clicking on the "start server" button should enlarge the window and show a scrollable logging window, which will display the server log messages. First, we'll resize the window:

function mainframe.startserver:onClicked()
  if not mainframe.serverruns then
    mainframe:setSize(180,300)
    mainframe.serverruns = true
    mainframe.startserver:setText("Stop Server")
    mainframe.startserver:setLocation(5,245)
    mainframe.startclient:setLocation(5,270)
  else
    mainframe:setSize(180,80)
    mainframe.serverruns = false
    mainframe.startserver:setText("Start Server")
    mainframe.startserver:setLocation(5,25)
    mainframe.startclient:setLocation(5,50)
  end
end

This will toggle now between the normal and the extended state:

Luxinia lacks a component that allows us to display huge text pages, but for games this is often not required anyway, and if, we can create a solution that fits our case the best. For example, we need now here a text output window which we integrate in the top of our mainmenu - it should only display the log messages. We therefore create a log of messages. When entering a message into the log, we split up the message into multiple line if the line is too long and we keep the record in a form that contains correctly wrapped words (as long as a single word does not become too long).

For displaying, we are using a simple label. The textprinting facility in Luxinia got updated in .97 so that we can always display text that contains multiple lines. We only have to care about the wordwrapping, which is done here. First we need the label to be inserted in our mainframe. We set the correct position but set the size to (0,0), and resize it if required:

mainframe.serverlog = mainframe:add(Label:new(10,28,160,0))
mainframe.serverlog:setAlignment(
  Label.LABEL_ALIGNLEFT,Label.LABEL_ALIGNTOP)
(...)
function mainframe.startserver:onClicked()
  if not mainframe.serverruns then
    (...)
    mainframe.serverlog:setSize(160,210)
  else
    (...)
    mainframe.serverlog:setSize(0,0)
  end
end

Of course, we won't see anything interesting by now, since there is no text displayed. We use the function setText to set the text of the label, however, we want to have a function that simplifies the logging. A reset function clears our log and a log function allows us to print text easily. We let the log function take a variable number of arguments - if no additional arguments are passed, we convert the first argument to a string, otherwise, we use it as a formating string, just as the good old printf that we can use in C. So we will be able to log messages like this: log("Client s) has connected",id,address), which is very useful for this case.

function mainframe.serverlog:reset()
  self.loglines = {}
end
function mainframe.serverlog:log(fmt,...)
  local line = select('#',...)==0 and tostring(fmt) 
    or fmt:format(...)
  local str = self:wrapLine(line)

The local variable str holds now the line that wraps correctly in the way that each line is not longer than the width of our label component. However, we want to store now each line seperated in our log, so we have to split the lines up, using a regular expression:

  for nl,line in str:gmatch("(\n?)([^\n]*)") do
    if #nl+#line>0 then
      table.insert(self.loglines,line)
    end
  end
  self:scrollto(#self.loglines)
end

Regular expressions are not the topic of this tutorial, but I try to explain in short what these lines do. The algorithm could be described this way:

If there is a newline in, take it and store it in a variable, for the following characters that are all not newlines, store them in the second variable and let this be done as often as this can be done with our line.
This is described by:
for nl,line in str:gmatch("(\n?)([^\n]*)") do
The (n?) means, that we take one or zero occurances of newline characters. The ([^\n]*) means, that we try to find as many characters that are not newline characters. The brackets are marking the begin and end of a so called capture. We can either refer to captures or retrieve the captured string that this capture described as a variable. So we store the first capture in nl and the rest of the line in the variable called line. We do this as often as possible. However, this pattern has the drawback, that it will match the final character position, resulting in a empty line at the end of the logging line. As each entry in the loglines table is a single line, we have to avoid to make an entry for this last character that is matched. We know that the last line character has been captured if both captured strings are of length 0.

Regular expressions are the most useful tool when comes to string processing, so if you have to do some string processing in your case, you should do more research on that if you donnot know them yet.

In the end of the log function above, we call a function named scrollto. This function has now to be written too, since this function is actually setting the label's displayed text to display our log:

function mainframe.serverlog:scrollto(line)
  line = line or #self.loglines
  local lines = {}
  for i=math.max(1,line-self:getMaxLines()+1),line do
    lines[#lines+1] = self.loglines[i]
  end
  self:setText(table.concat(lines,"\n"))
end

This function allows us to scroll to any line of the log and sets the label's output that way. As a small test, we could fill up the log with a few lines:

for i=1,30 do 
  mainframe.serverlog:log(
    "Logging line %i, it is %s o'clock",i,os.date("%H:%M")) 
end
This code will result in this output:

We are now ready to start the server and print its logged output on our logging window. We extend the code that is executed when the user clicks on the start server button in the way, that the timer is set:

function mainframe.startserver:onClicked()
  if mainframe.serverruns==nil then
    (...)
    mainframe.serverlog:scrollto()
    Timer.set("Server",startserver,50)
  else
    (...)
    mainframe.serverruns = false
    (...)
  end
end
The function startserver looks like this:
function startserver ()
  mainframe.serverruns = true
  local function logger(...)
    mainframe.serverlog:log(...)
  end
  local function closed ()
    return not mainframe.serverruns
  end
  local function broadcast ()
  end
  server (logger,closed,broadcast)
  Timer.remove("Server")
  mainframe.serverruns = nil
end
The local functions are the callbacks that are called by the server code. The logger function just forwards to our logging function and the closed function returns false as long as the server is running.

You might also realized that the mainframe.serverruns is now no longe used only as a true/false value. Instead, it is set to nil if the server is shutdown, it is set to true if the server is running and set to false to tell that the server should be shut down now, which will set the variable to nil again. This is important because otherwise, we could try to start a server while the previous one is not shutdown yet, displaying an error message only. Eventually the server could jump on if the garbage collector catches the udp socket (which is no longer in reach because the timer function will be reseted which causes the coroutine to be collected as well), but this is certainly not what we want. Always keep in mind that even though that luxinia is not multithreaded, we still might have asynchron code calls, which means that our inputs like keypresses or mouseclicks can run into deadlocks and everything else that we know from multithreaded code. The difference is, that we are actually able to predict such wrong behaviour (and can reproduce it in a quite reliable manner).

Step 3: The client windows

We want now to be able to start clients which will open a dragable window, connecting to a server that we can edit there. We realize now, that there are redundant elements:

  • The logging label of the server - it will be required for the server as well
  • The dragging code for the window
With a bit foresight, we could have realized this redundancy and could have written a function or class (-like, lua does not support a classlike system) that could handle both actions.

However, first this would have delayed the tutorial because both elements would have been first prepared and developed (and this tutorial should be as straightforward as possible) and second, we can still do so without too much work. We only need to change a few lines of code, which looks like this right now:

------- moving the frame
function mainframe:mousePressed(me)
(...)
end

function mainframe:mouseReleased(me)
(...)
end

function mainframe:mouseMoved(me)
(...)
end

All what we will do now is:

local function window_mousePressed(self,me) (...)
mainframe.mousePressed = window_mousePressed

local function window_mouseReleased(self,me) (...)
mainframe.mouseReleased = window_mouseReleased

local function window_mouseMoved(self,me) (...)
mainframe.mouseMoved = window_mouseMoved

So all we need to do is to now is to set these three functions of any component to theses functions in order to behave them this way. And so we'll do with our client windows. Actually, we even wouldn't have to change these, we could have used the functions directly and assign it to other components as well, but for the sake of readability, this should be avoided.

We also want now that the window that is clicked in the title is moved to the front, so we add

local function window_mousePressed(self,me,contains)
  if not contains then return end
  self:getParent():moveZ(self,1) -- move to front
(...)
where the first line cares about mouseevents that are within the rectangle of the window, but not on the component because another component is in front of the mouse. For our logging label, we chose a little bit different way: We will write a function that creates a label and return it then, which doesn't require much code to be changed either:

function createLogLabel (...)
  local label = Label:new(...)
  label:setAlignment(
    Label.LABEL_ALIGNLEFT,Label.LABEL_ALIGNTOP)

  function label:reset() self.loglines = {} end

  function label:log(fmt,...)
    local line = select('#',...)==0 and tostring(fmt) 
      or fmt:format(...)
    local str = self:wrapLine(line)
    for nl,line in str:gmatch("(\n?)([^\n]*)") do
      if #nl+#line>0 then
        table.insert(self.loglines,line)
      end
    end
    self:scrollto()
  end
  function label:scrollto(line)
    line = line or #self.loglines
    local lines = {}
    for i=math.max(1,line-self:getMaxLines()+1),line do
      lines[#lines+1] = self.loglines[i]
    end
    self:setText(table.concat(lines,"\n"))
  end
  label:reset()
  return label
end

mainframe.serverlog = mainframe:add(
  createLogLabel(10,28,160,0))

So, adding a loglabel is quite easy now.

Starting the client runs first in the "start client" button action:

function mainframe.startclient:onClicked()
  local window = TitleFrame:new(
    mainframe:getX()+mainframe:getWidth(),mainframe:getY(),
    200,150,nil,"Client")
  Container.getRootContainer():add(window,1)
  window.mousePressed = window_mousePressed
  window.mouseReleased = window_mouseReleased
  window.mouseMoved = window_mouseMoved

  local close = window:add(Button:new(168,4,30,20,"exit"))

Our first action is to create a window and add an exit button to the top right and assigning the functions that allow us to move the window.

The following block creates a panel that contains a textfield and a connect button, which allows us to chose a servername. The local running is a variable which is true if the server runs, false if it should be stopped and nil if it is stopped.

  local running

  local conpanel = window:add(GroupFrame:new(10,40,180,80))
  conpanel:add(Label:new(10,10,160,16,"Server adress:"))
  local serveradr = conpanel:add(TextField:new(10,26,160,20))
  local connect = conpanel:add(Button:new(100,48,70,20,
    "connect"))
  serveradr:setText("localhost")

After this, we create a panel that contains the loglabel, a textfield for typing messages and a send and disconnect button (while sending can also be done by using the enter key when the textfield is focused)

  local chatpanel = GroupFrame:new(4,24,194,124)
  local log = chatpanel:add(createLogLabel(5,5,170,90))
  local sendtx = chatpanel:add(TextField:new(5,95,120,20))
  local send = chatpanel:add(Button:new(122,95,40,20,"Send"))
  local bye = chatpanel:add(Button:new(160,95,30,20,"Bye"))

When the send button is clicked (or the enter key was pressed), we will fill up a queue with the content of the textfield:

  local sendqueue = {}

  function send:onClicked()
    table.insert(sendqueue,sendtx:getText())
    sendtx:setText("")
  end

  sendtx.onAction = send.onClicked

Disconnecting or quitting the client is also quite simple:


  function bye:onClicked()
    running = false
  end

  function close:onClicked()
    running = false
    window:remove()
  end

Connecting now to an server is a little bit more complex, as the client's function awaits again a few callback functions for communication:

  function connect:onClicked()
    if running~=nil then return end

    running = true

    conpanel:remove()
    window:add(chatpanel)

    local function closeit () return not running end
    local function receiver (...) log:log(...) end
    local function sender () 
      return table.remove(sendqueue,1)
    end

The first code line takes care about not connecting while the server is running or shutting down.

After this, we set the local running to true, flagging that the client is now started and remove the connectparameter panel while adding the chat panel. After this, we define our callback functions, which return what is to be sent or which fill the log with the results. If you wonder on the screenshots how the printed text becomes colorful, you'll have to look into the udp.lua file: The calling function colors the string by setting the control characters for the printing. Following control characters are available now:

vrgbr,g,b stands for numbers between 0-9 and assemble a colorvalue 999 is white, 900 red and so on
vcrestore original color
vCcenter the current line
vRalign the current line to the right
vxn;n stands for a decimal value - jumps to the relative x coordinate (where left is where the printing started)

So far, we have everything, except starting the client, which is done using a timertask (timertasks are one shot function calls which won't repeat like timers - we can yield anytime however and continue execution later, just like for timers, except that we can chose how long the delay should be):

TimerTask.new(
      function ()
        client(serveradr:getText(),receiver,sender,closeit)
        running = nil
        chatpanel:remove()
        window:add(conpanel)
      end,50)
  end

end

So this is all, except for:


Container.getRootContainer():add(mainframe)

MouseCursor.showMouse(true)

Which adds the frame to the rootcontainer, making it finally visible. And of course, we'd like to view the mouse.

The endresult looks like this: