4b07157fea76bff77ea45c48c7bfb51fa7a955e1
[vlc.git] / share / lua / intf / rc.lua
1 --[==========================================================================[
2  rc.lua: remote control module for VLC
3 --[==========================================================================[
4  Copyright (C) 2007-2009 the VideoLAN team
5  $Id$
6
7  Authors: Antoine Cellerier <dionoea at videolan dot org>
8
9  This program is free software; you can redistribute it and/or modify
10  it under the terms of the GNU General Public License as published by
11  the Free Software Foundation; either version 2 of the License, or
12  (at your option) any later version.
13
14  This program is distributed in the hope that it will be useful,
15  but WITHOUT ANY WARRANTY; without even the implied warranty of
16  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17  GNU General Public License for more details.
18
19  You should have received a copy of the GNU General Public License
20  along with this program; if not, write to the Free Software
21  Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston MA 02110-1301, USA.
22 --]==========================================================================]
23
24 description=
25 [============================================================================[
26  Remote control interface for VLC
27
28  This is a modules/control/rc.c look alike (with a bunch of new features)
29
30  Use on local term:
31     vlc -I luarc
32  Use on tcp connection:
33     vlc -I luarc --lua-config "rc={host='localhost:4212'}"
34  Use on multiple hosts (term + 2 tcp ports):
35     vlc -I luarc --lua-config "rc={hosts={'*console','localhost:4212','localhost:5678'}}"
36
37  Note:
38     -I luarc is an alias for -I lua --lua-intf rc
39
40  Configuration options setable throught the --lua-config option are:
41     * hosts: A list of hosts to listen on.
42     * host: A host to listen on. (won't be used if `hosts' is set)
43  The following can be set using the --lua-config option or in the interface
44  itself using the `set' command:
45     * prompt: The prompt.
46     * welcome: The welcome message.
47     * width: The default terminal width (used to format text).
48     * autocompletion: When issuing an unknown command, print a list of
49                       possible commands to autocomplete with. (0 to disable,
50                       1 to enable).
51     * autoalias: If autocompletion returns only one possibility, use it
52                  (0 to disable, 1 to enable).
53     * flatplaylist: 0 to disable, 1 to enable.
54 ]============================================================================]
55
56 require("common")
57 skip = common.skip
58 skip2 = function(foo) return skip(skip(foo)) end
59 setarg = common.setarg
60 strip = common.strip
61
62 --[[ Setup default environement ]]
63 env = { prompt = "> ";
64         width = 70;
65         autocompletion = 1;
66         autoalias = 1;
67         welcome = "Remote control interface initialized. Type `help' for help.";
68         flatplaylist = 0;
69       }
70
71 --[[ Import custom environement variables from the command line config (if possible) ]]
72 for k,v in pairs(env) do
73     if config[k] then
74         if type(env[k]) == type(config[k]) then
75             env[k] = config[k]
76             vlc.msg.dbg("set environement variable `"..k.."' to "..tostring(env[k]))
77         else
78             vlc.msg.err("environement variable `"..k.."' should be of type "..type(env[k])..". config value will be discarded.")
79         end
80     end
81 end
82
83 --[[ Command functions ]]
84 function set_env(name,client,value)
85     if value then
86         local var,val = split_input(value)
87         if val then
88             s = string.gsub(val,"\"(.*)\"","%1")
89             if type(client.env[var])==type(1) then
90                 client.env[var] = tonumber(s)
91             else
92                 client.env[var] = s
93             end
94         else
95             client:append( tostring(client.env[var]) )
96         end
97     else
98         for e,v in common.pairs_sorted(client.env) do
99             client:append(e.."="..v)
100         end
101     end
102 end
103
104 function save_env(name,client,value)
105     env = common.table_copy(client.env)
106 end
107
108 function alias(client,value)
109     if value then
110         local var,val = split_input(value)
111         if commands[var] and type(commands[var]) ~= type("") then
112             client:append("Error: cannot use a primary command as an alias name")
113         else
114             if commands[val] then
115                 commands[var]=val
116             else
117                 client:append("Error: unknown primary command `"..val.."'.")
118             end
119         end
120     else
121         for c,v in common.pairs_sorted(commands) do
122             if type(v)==type("") then
123                 client:append(c.."="..v)
124             end
125         end
126     end
127 end
128
129 function fixme(name,client)
130     client:append( "FIXME: unimplemented command `"..name.."'." )
131 end
132
133 function logout(name,client)
134     if client.type == host.client_type.net then
135         client:send("Bye-bye!")
136         client:del()
137     else
138         client:append("Error: Can't logout of stdin/stdout. Use quit or shutdown to close VLC.")
139     end
140 end
141
142 function shutdown(name,client)
143     client:append("Bye-bye!")
144     h:broadcast("Shutting down.")
145     vlc.msg.info("Requested shutdown.")
146     vlc.misc.quit()
147 end
148
149 function quit(name,client)
150     if client.type == host.client_type.net then
151         logout(name,client)
152     else
153         shutdown(name,client)
154     end
155 end
156
157 function add(name,client,arg)
158     -- TODO: par single and double quotes properly
159     local f
160     if name == "enqueue" then
161         f = vlc.playlist.enqueue
162     else
163         f = vlc.playlist.add
164     end
165     local options = {}
166     for o in string.gmatch(arg," +:([^ ]*)") do
167         table.insert(options,o)
168     end
169     arg = string.gsub(arg," +:.*$","")
170     f({{path=arg,options=options}})
171 end
172
173 function playlist_is_tree( client )
174     if client.env.flatplaylist == 0 then
175         return true
176     else
177         return false
178     end
179 end
180
181 function playlist(name,client,arg)
182     function playlist0(item,prefix)
183         local prefix = prefix or ""
184         if not item.flags.disabled then
185             local str = "| "..prefix..tostring(item.id).." - "..item.name
186             if item.duration > 0 then
187                 str = str.." ("..common.durationtostring(item.duration)..")"
188             end
189             if item.nb_played > 0 then
190                 str = str.." [played "..tostring(item.nb_played).." time"
191                 if item.nb_played > 1 then
192                     str = str .. "s"
193                 end
194                 str = str .. "]"
195             end
196             client:append(str)
197         end
198         if item.children then
199             for _, c in ipairs(item.children) do
200                 playlist0(c,prefix.."  ")
201             end
202         end
203     end
204     local playlist
205     local tree = playlist_is_tree(client)
206     if name == "search" then
207         playlist = vlc.playlist.search(arg or "", tree)
208     else
209         if tonumber(arg) then
210             playlist = vlc.playlist.get(tonumber(arg), tree)
211         elseif arg then
212             playlist = vlc.playlist.get(arg, tree)
213         else
214             playlist = vlc.playlist.get(nil, tree)
215         end
216     end
217     if name == "search" then
218         client:append("+----[ Search - "..(arg or "`reset'").." ]")
219     else
220         client:append("+----[ Playlist - "..playlist.name.." ]")
221     end
222     if playlist.children then
223         for _, item in ipairs(playlist.children) do
224             playlist0(item)
225         end
226     else
227         playlist0(playlist)
228     end
229     if name == "search" then
230         client:append("+----[ End of search - Use `search' to reset ]")
231     else
232         client:append("+----[ End of playlist ]")
233     end
234 end
235
236 function playlist_sort(name,client,arg)
237     if not arg then
238         client:append("Valid sort keys are: id, title, artist, genre, random, duration, album.")
239     else
240         local tree = playlist_is_tree(client)
241         vlc.playlist.sort(arg,false,tree)
242     end
243 end
244
245 function services_discovery(name,client,arg)
246     if arg then
247         if vlc.sd.is_loaded(arg) then
248             vlc.sd.remove(arg)
249             client:append(arg.." disabled.")
250         else
251             vlc.sd.add(arg)
252             client:append(arg.." enabled.")
253         end
254     else
255         local sd = vlc.sd.get_services_names()
256         client:append("+----[ Services discovery ]")
257         for n,ln in pairs(sd) do
258             local status
259             if vlc.sd.is_loaded(n) then
260                 status = "enabled"
261             else
262                 status = "disabled"
263             end
264             client:append("| "..n..": " .. ln .. " (" .. status .. ")")
265         end
266         client:append("+----[ End of services discovery ]")
267     end
268 end
269
270 function print_text(label,text)
271     return function(name,client)
272         client:append("+----[ "..label.." ]")
273         client:append "|"
274         for line in string.gmatch(text,".-\r?\n") do
275             client:append("| "..string.gsub(line,"\r?\n",""))
276         end
277         client:append "|"
278         client:append("+----[ End of "..string.lower(label).." ]")
279     end
280 end
281
282 function help(name,client,arg)
283     local width = client.env.width
284     local long = (name == "longhelp")
285     local extra = ""
286     if arg then extra = "matching `" .. arg .. "' " end
287     client:append("+----[ Remote control commands "..extra.."]")
288     for i, cmd in ipairs(commands_ordered) do
289         if (cmd == "" or not commands[cmd].adv or long)
290         and (not arg or string.match(cmd,arg)) then
291             local str = "| " .. cmd
292             if cmd ~= "" then
293                 local val = commands[cmd]
294                 if val.aliases then
295                     for _,a in ipairs(val.aliases) do
296                         str = str .. ", " .. a
297                     end
298                 end
299                 if val.args then str = str .. " " .. val.args end
300                 if #str%2 == 1 then str = str .. " " end
301                 str = str .. string.rep(" .",(width-(#str+#val.help)-1)/2)
302                 str = str .. string.rep(" ",width-#str-#val.help) .. val.help
303             end
304             client:append(str)
305         end
306     end
307     client:append("+----[ end of help ]")
308 end
309
310 function input_info(name,client)
311     local categories = vlc.input.info()
312     for cat, infos in pairs(categories) do
313         client:append("+----[ "..cat.." ]")
314         client:append("|")
315         for name, value in pairs(infos) do
316             client:append("| "..name..": "..value)
317         end
318         client:append("|")
319     end
320     client:append("+----[ end of stream info ]")
321 end
322
323 function playlist_status(name,client)
324     local a,b,c = vlc.playlist.status()
325     client:append( "( new input: " .. tostring(a) .. " )" )
326     client:append( "( audio volume: " .. tostring(b) .. " )")
327     client:append( "( state " .. tostring(c) .. " )")
328 end
329
330 function is_playing(name,client)
331     if vlc.input.is_playing() then client:append "1" else client:append "0" end
332 end
333
334 function ret_print(foo,start,stop)
335     local start = start or ""
336     local stop = stop or ""
337     return function(discard,client,...) client:append(start..tostring(foo(...))..stop) end
338 end
339
340 function get_time(var)
341     return function(name,client)
342         local input = vlc.object.input()
343         client:append(math.floor(vlc.var.get( input, var )))
344     end
345 end
346
347 function titlechap(name,client,value)
348     local input = vlc.object.input()
349     local var = string.gsub( name, "_.*$", "" )
350     if value then
351         vlc.var.set( input, var, value )
352     else
353         local item = vlc.var.get( input, var )
354         -- Todo: add item name conversion
355         client:append(item)
356     end
357 end
358 function titlechap_offset(client,offset)
359     return function(name,value)
360         local input = vlc.object.input()
361         local var = string.gsub( name, "_.*$", "" )
362         vlc.var.set( input, var, vlc.var.get( input, var )+offset )
363     end
364 end
365
366 function seek(name,client,value)
367     common.seek(value)
368 end
369
370 function volume(name,client,value)
371     if value then
372         vlc.volume.set(value)
373     else
374         client:append(tostring(vlc.volume.get()))
375     end
376 end
377
378 function rate(name,client)
379     local input = vlc.object.input()
380     if name == "normal" then
381         vlc.var.set(input,"rate",1000) -- FIXME: INPUT_RATE_DEFAULT
382     else
383         vlc.var.set(input,"rate-"..name,nil)
384     end
385 end
386
387 function frame(name,client)
388     vlc.var.trigger_callback(vlc.object.input(),"frame-next");
389 end
390
391 function listvalue(obj,var)
392     return function(client,value)
393         local o = vlc.object.find(nil,obj,"anywhere")
394         if not o then return end
395         if value then
396             vlc.var.set( o, var, value )
397         else
398             local c = vlc.var.get( o, var )
399             local v, l = vlc.var.get_list( o, var )
400             client:append("+----[ "..var.." ]")
401             for i,val in ipairs(v) do
402                 local mark = (val==c)and " *" or ""
403                 client:append("| "..tostring(val).." - "..tostring(l[i])..mark)
404             end
405             client:append("+----[ end of "..var.." ]")
406         end
407     end
408 end
409
410 function eval(client,val)
411     client:append(loadstring("return "..val)())
412 end
413
414 --[[ Declare commands, register their callback functions and provide
415      help strings here.
416      Syntax is:
417      "<command name>"; { func = <function>; [ args = "<str>"; ] help = "<str>"; [ adv = <bool>; ] [ aliases = { ["<str>";]* }; ] }
418      ]]
419 commands_ordered = {
420     { "add"; { func = add; args = "XYZ"; help = "add XYZ to playlist" } };
421     { "enqueue"; { func = add; args = "XYZ"; help = "queue XYZ to playlist" } };
422     { "playlist"; { func = playlist; help = "show items currently in playlist" } };
423     { "search"; { func = playlist; args = "[string]"; help = "search for items in playlist (or reset search)" } };
424     { "sort"; { func = playlist_sort; args = "key"; help = "sort the playlist" } };
425     { "sd"; { func = services_discovery; args = "[sd]"; help = "show services discovery or toggle" } };
426     { "play"; { func = skip2(vlc.playlist.play); help = "play stream" } };
427     { "stop"; { func = skip2(vlc.playlist.stop); help = "stop stream" } };
428     { "next"; { func = skip2(vlc.playlist.next); help = "next playlist item" } };
429     { "prev"; { func = skip2(vlc.playlist.prev); help = "previous playlist item" } };
430     { "goto"; { func = skip2(vlc.playlist.goto); help = "goto item at index" } };
431     { "repeat"; { func = skip2(vlc.playlist.repeat_); args = "[on|off]"; help = "toggle playlist repeat" } };
432     { "loop"; { func = skip2(vlc.playlist.loop); args = "[on|off]"; help = "toggle playlist loop" } };
433     { "random"; { func = skip2(vlc.playlist.random); args = "[on|off]"; help = "toggle playlist random" } };
434     { "clear"; { func = skip2(vlc.playlist.clear); help = "clear the playlist" } };
435     { "status"; { func = playlist_status; help = "current playlist status" } };
436     { "title"; { func = titlechap; args = "[X]"; help = "set/get title in current item" } };
437     { "title_n"; { func = titlechap_offset(1); help = "next title in current item" } };
438     { "title_p"; { func = titlechap_offset(-1); help = "previous title in current item" } };
439     { "chapter"; { func = titlechap; args = "[X]"; help = "set/get chapter in current item" } };
440     { "chapter_n"; { func = titlechap_offset(1); help = "next chapter in current item" } };
441     { "chapter_p"; { func = titlechap_offset(-1); help = "previous chapter in current item" } };
442     { "" };
443     { "seek"; { func = seek; args = "X"; help = "seek in seconds, for instance `seek 12'" } };
444     { "pause"; { func = setarg(common.hotkey,"key-play-pause"); help = "toggle pause" } };
445     { "fastforward"; { func = setarg(common.hotkey,"key-jump+extrashort"); help = "set to maximum rate" } };
446     { "rewind"; { func = setarg(common.hotkey,"key-jump-extrashort"); help = "set to minimum rate" } };
447     { "faster"; { func = rate; help = "faster playing of stream" } };
448     { "slower"; { func = rate; help = "slower playing of stream" } };
449     { "normal"; { func = rate; help = "normal playing of stream" } };
450     { "frame"; { func = frame; help = "play frame by frame" } };
451     { "fullscreen"; { func = skip2(vlc.video.fullscreen); args = "[on|off]"; help = "toggle fullscreen"; aliases = { "f", "F" } } };
452     { "info"; { func = input_info; help = "information about the current stream" } };
453     { "states"; { func = fixme; help = "show statistical information" } };
454     { "get_time"; { func = get_time("time"); help = "seconds elapsed since stream's beginning" } };
455     { "is_playing"; { func = is_playing; help = "1 if a stream plays, 0 otherwise" } };
456     { "get_title"; { func = ret_print(vlc.input.get_title); help = "the title of the current stream" } };
457     { "get_length"; { func = get_time("length"); help = "the length of the current stream" } };
458     { "" };
459     { "volume"; { func = volume; args = "[X]"; help = "set/get audio volume" } };
460     { "volup"; { func = ret_print(vlc.volume.up,"( audio volume: "," )"); args = "[X]"; help = "raise audio volume X steps" } };
461     { "voldown"; { func = ret_print(vlc.volume.down,"( audio volume: "," )"); args = "[X]"; help = "lower audio volume X steps" } };
462     { "adev"; { func = skip(listvalue("aout","audio-device")); args = "[X]"; help = "set/get audio device" } };
463     { "achan"; { func = skip(listvalue("aout","audio-channels")); args = "[X]"; help = "set/get audio channels" } };
464     { "atrack"; { func = skip(listvalue("input","audio-es")); args = "[X]"; help = "set/get audio track" } };
465     { "vtrack"; { func = skip(listvalue("input","video-es")); args = "[X]"; help = "set/get video track" } };
466     { "vratio"; { func = skip(listvalue("vout","aspect-ratio")); args = "[X]"; help = "set/get video aspect ratio" } };
467     { "vcrop"; { func = skip(listvalue("vout","crop")); args = "[X]"; help = "set/get video crop"; aliases = { "crop" } } };
468     { "vzoom"; { func = skip(listvalue("vout","zoom")); args = "[X]"; help = "set/get video zoom"; aliases = { "zoom" } } };
469     { "snapshot"; { func = common.snapshot; help = "take video snapshot" } };
470     { "strack"; { func = skip(listvalue("input","spu-es")); args = "[X]"; help = "set/get subtitles track" } };
471     { "hotkey"; { func = skip(common.hotkey); args = "[hotkey name]"; help = "simulate hotkey press"; adv = true; aliases = { "key" } } };
472     { "menu"; { func = fixme; args = "[on|off|up|down|left|right|select]"; help = "use menu"; adv = true } };
473     { "" };
474     { "set"; { func = set_env; args = "[var [value]]"; help = "set/get env var"; adv = true } };
475     { "save_env"; { func = save_env; help = "save env vars (for future clients)"; adv = true } };
476     { "alias"; { func = skip(alias); args = "[cmd]"; help = "set/get command aliases"; adv = true } };
477     { "eval"; { func = skip(eval); help = "eval some lua (*debug*)"; adv =true } }; -- FIXME: comment out if you're not debugging
478     { "description"; { func = print_text("Description",description); help = "describe this module" } };
479     { "license"; { func = print_text("License message",vlc.misc.license()); help = "print VLC's license message"; adv = true } };
480     { "help"; { func = help; args = "[pattern]"; help = "a help message"; aliases = { "?" } } };
481     { "longhelp"; { func = help; args = "[pattern]"; help = "a longer help message" } };
482     { "logout"; { func = logout; help = "exit (if in a socket connection)" } };
483     { "quit"; { func = quit; help = "quit VLC (or logout if in a socket connection)" } };
484     { "shutdown"; { func = shutdown; help = "shutdown VLC" } };
485     }
486 commands = {}
487 for i, cmd in ipairs( commands_ordered ) do
488     if #cmd == 2 then
489         commands[cmd[1]]=cmd[2]
490         if cmd[2].aliases then
491             for _,a in ipairs(cmd[2].aliases) do
492                 commands[a]=cmd[1]
493             end
494         end
495     end
496     commands_ordered[i]=cmd[1]
497 end
498 --[[ From now on commands_ordered is a list of the different command names
499      and commands is a associative array indexed by the command name. ]]
500
501 -- Compute the column width used when printing a the autocompletion list
502 env.colwidth = 0
503 for c,_ in pairs(commands) do
504     if #c > env.colwidth then env.colwidth = #c end
505 end
506 env.coldwidth = env.colwidth + 1
507
508 -- Count unimplemented functions
509 do
510     local count = 0
511     local list = "("
512     for c,v in pairs(commands) do
513         if v.func == fixme then
514             count = count + 1
515             if count ~= 1 then
516                 list = list..","
517             end
518             list = list..c
519         end
520     end
521     list = list..")"
522     if count ~= 0 and env.welcome then
523         env.welcome = env.welcome .. "\r\nWarning: "..count.." functions are still unimplemented "..list.."."
524     end
525 end
526
527 --[[ Utils ]]
528 function split_input(input)
529     local input = strip(input)
530     local s = string.find(input," ")
531     if s then
532         return string.sub(input,0,s-1), strip(string.sub(input,s))
533     else
534         return input
535     end
536 end
537
538 function call_command(cmd,client,arg)
539     if type(commands[cmd]) == type("") then
540         cmd = commands[cmd]
541     end
542     local ok, msg
543     if arg ~= nil then
544         ok, msg = pcall( commands[cmd].func, cmd, client, arg )
545     else
546         ok, msg = pcall( commands[cmd].func, cmd, client )
547     end
548     if not ok then
549         local a = arg or ""
550         if a ~= "" then a = " " .. a end
551         client:append("Error in `"..cmd..a.."' ".. msg)
552     end
553 end
554
555 function call_libvlc_command(cmd,client,arg)
556     local ok, vlcerr, vlcmsg = pcall( vlc.var.libvlc_command, cmd, arg )
557     if not ok then
558         local a = arg or ""
559         if a ~= "" then a = " " .. a end
560         client:append("Error in `"..cmd..a.."' ".. vlcerr) -- when pcall fails, the 2nd arg is the error message.
561     end
562     return vlcerr
563 end
564
565 function call_object_command(cmd,client,arg)
566     local var, val = split_input(arg)
567     local ok, vlcmsg, vlcerr, vlcerrmsg = pcall( vlc.var.command, cmd, var, val )
568     if not ok then
569         client:append("Error in `"..cmd.." "..var.." "..val.."' ".. vlcmsg) -- when pcall fails the 2nd arg is the error message
570     end
571     if vlcmsg ~= "" then
572         client:append(vlcmsg)
573     end
574     return vlcerr
575 end
576
577 --[[ Setup host ]]
578 require("host")
579 h = host.host()
580 -- No auth
581 h.status_callbacks[host.status.password] = function(client)
582     client.env = common.table_copy( env )
583     client:send( client.env.welcome .. "\r\n")
584     client:switch_status(host.status.read)
585 end
586 -- Print prompt when switching a client's status to `read'
587 h.status_callbacks[host.status.read] = function(client)
588     client:send( client.env.prompt )
589 end
590
591 h:listen( config.hosts or config.host or "*console" )
592
593 --[[ The main loop ]]
594 while not vlc.misc.should_die() do
595     h:accept()
596     local write, read = h:select(0.1)
597
598     for _, client in pairs(write) do
599         local len = client:send()
600         client.buffer = string.sub(client.buffer,len+1)
601         if client.buffer == "" then client:switch_status(host.status.read) end
602     end
603
604     for _, client in pairs(read) do
605         local input = client:recv(1000)
606         local done = false
607         if string.match(input,"\n$") then
608             client.buffer = string.gsub(client.buffer..input,"\r?\n$","")
609             done = true
610         elseif client.buffer == ""
611            and ((client.type == host.client_type.stdio and input == "")
612            or  (client.type == host.client_type.net and input == "\004")) then
613             -- Caught a ^D
614             client.buffer = "quit"
615             done = true
616         else
617             client.buffer = client.buffer .. input
618         end
619         if done then
620             local cmd,arg = split_input(client.buffer)
621             client.buffer = ""
622             client:switch_status(host.status.write)
623             if commands[cmd] then
624                 call_command(cmd,client,arg)
625             elseif string.sub(cmd,0,1)=='@'
626             and call_object_command(string.sub(cmd,2,#cmd),client,arg) == 0 then
627                 --
628             elseif client.type == host.client_type.stdio
629             and call_libvlc_command(cmd,client,arg) == 0 then
630                 --
631             else
632                 local choices = {}
633                 if client.env.autocompletion ~= 0 then
634                     for v,_ in common.pairs_sorted(commands) do
635                         if string.sub(v,0,#cmd)==cmd then
636                             table.insert(choices, v)
637                         end
638                     end
639                 end
640                 if #choices == 1 and client.env.autoalias ~= 0 then
641                     -- client:append("Aliasing to \""..choices[1].."\".")
642                     cmd = choices[1]
643                     call_command(cmd,client,arg)
644                 else
645                     client:append("Unknown command `"..cmd.."'. Type `help' for help.")
646                     if #choices ~= 0 then
647                         client:append("Possible choices are:")
648                         local cols = math.floor(client.env.width/(client.env.colwidth+1))
649                         local fmt = "%-"..client.env.colwidth.."s"
650                         for i = 1, #choices do
651                             choices[i] = string.format(fmt,choices[i])
652                         end
653                         for i = 1, #choices, cols do
654                             local j = i + cols - 1
655                             if j > #choices then j = #choices end
656                             client:append("  "..table.concat(choices," ",i,j))
657                         end
658                     end
659                 end
660             end
661         end
662     end
663 end