· 5 years ago · Jan 05, 2021, 05:52 PM
1--- Multishell allows multiple programs to be run at the same time.
2--
3-- When multiple programs are running, it displays a tab bar at the top of the
4-- screen, which allows you to switch between programs. New programs can be
5-- launched using the `fg` or `bg` programs, or using the @{shell.openTab} and
6-- @{multishell.launch} functions.
7--
8-- Each process is identified by its ID, which corresponds to its position in
9-- the tab list. As tabs may be opened and closed, this ID is _not_ constant
10-- over a program's run. As such, be careful not to use stale IDs.
11--
12-- As with @{shell}, @{multishell} is not a "true" API. Instead, it is a
13-- standard program, which launches a shell and injects its API into the shell's
14-- environment. This API is not available in the global environment, and so is
15-- not available to @{os.loadAPI|APIs}.
16--
17-- @module[module] multishell
18
19local expect = dofile("rom/modules/main/cc/expect.lua").expect
20
21-- Setup process switching
22local parentTerm = term.current()
23local w, h = parentTerm.getSize()
24
25local tProcesses = {}
26local nCurrentProcess = nil
27local nRunningProcess = nil
28local bShowMenu = false
29local bWindowsResized = false
30local nScrollPos = 1
31local bScrollRight = false
32
33local function selectProcess(n)
34 if nCurrentProcess ~= n then
35 if nCurrentProcess then
36 local tOldProcess = tProcesses[nCurrentProcess]
37 tOldProcess.window.setVisible(false)
38 end
39 nCurrentProcess = n
40 if nCurrentProcess then
41 local tNewProcess = tProcesses[nCurrentProcess]
42 tNewProcess.window.setVisible(true)
43 tNewProcess.bInteracted = true
44 end
45 end
46end
47
48local function setProcessTitle(n, sTitle)
49 tProcesses[n].sTitle = sTitle
50end
51
52local function resumeProcess(nProcess, sEvent, ...)
53 local tProcess = tProcesses[nProcess]
54 local sFilter = tProcess.sFilter
55 if sFilter == nil or sFilter == sEvent or sEvent == "terminate" then
56 local nPreviousProcess = nRunningProcess
57 nRunningProcess = nProcess
58 term.redirect(tProcess.terminal)
59 local ok, result = coroutine.resume(tProcess.co, sEvent, ...)
60 tProcess.terminal = term.current()
61 if ok then
62 tProcess.sFilter = result
63 else
64 printError(result)
65 end
66 nRunningProcess = nPreviousProcess
67 end
68end
69
70local function launchProcess(bFocus, tProgramEnv, sProgramPath, ...)
71 local tProgramArgs = table.pack(...)
72 local nProcess = #tProcesses + 1
73 local tProcess = {}
74 tProcess.sTitle = fs.getName(sProgramPath)
75 if bShowMenu then
76 tProcess.window = window.create(parentTerm, 1, 2, w, h - 1, false)
77 else
78 tProcess.window = window.create(parentTerm, 1, 1, w, h, false)
79 end
80 tProcess.co = coroutine.create(function()
81 os.run(tProgramEnv, sProgramPath, table.unpack(tProgramArgs, 1, tProgramArgs.n))
82 if not tProcess.bInteracted then
83 term.setCursorBlink(false)
84 print("Press any key to continue")
85 os.pullEvent("char")
86 end
87 end)
88 tProcess.sFilter = nil
89 tProcess.terminal = tProcess.window
90 tProcess.bInteracted = false
91 tProcesses[nProcess] = tProcess
92 if bFocus then
93 selectProcess(nProcess)
94 end
95 resumeProcess(nProcess)
96 return nProcess
97end
98
99local function cullProcess(nProcess)
100 local tProcess = tProcesses[nProcess]
101 if coroutine.status(tProcess.co) == "dead" then
102 if nCurrentProcess == nProcess then
103 selectProcess(nil)
104 end
105 table.remove(tProcesses, nProcess)
106 if nCurrentProcess == nil then
107 if nProcess > 1 then
108 selectProcess(nProcess - 1)
109 elseif #tProcesses > 0 then
110 selectProcess(1)
111 end
112 end
113 if nScrollPos ~= 1 then
114 nScrollPos = nScrollPos - 1
115 end
116 return true
117 end
118 return false
119end
120
121local function cullProcesses()
122 local culled = false
123 for n = #tProcesses, 1, -1 do
124 culled = culled or cullProcess(n)
125 end
126 return culled
127end
128
129-- Setup the main menu
130local menuMainTextColor, menuMainBgColor, menuOtherTextColor, menuOtherBgColor
131if parentTerm.isColor() then
132 menuMainTextColor, menuMainBgColor = colors.yellow, colors.black
133 menuOtherTextColor, menuOtherBgColor = colors.black, colors.gray
134else
135 menuMainTextColor, menuMainBgColor = colors.white, colors.black
136 menuOtherTextColor, menuOtherBgColor = colors.black, colors.gray
137end
138
139local function redrawMenu()
140 if bShowMenu then
141 -- Draw menu
142 parentTerm.setCursorPos(1, 1)
143 parentTerm.setBackgroundColor(menuOtherBgColor)
144 parentTerm.clearLine()
145 local nCharCount = 0
146 local nSize = parentTerm.getSize()
147 if nScrollPos ~= 1 then
148 parentTerm.setTextColor(menuOtherTextColor)
149 parentTerm.setBackgroundColor(menuOtherBgColor)
150 parentTerm.write("<")
151 nCharCount = 1
152 end
153 for n = nScrollPos, #tProcesses do
154 if n == nCurrentProcess then
155 parentTerm.setTextColor(menuMainTextColor)
156 parentTerm.setBackgroundColor(menuMainBgColor)
157 else
158 parentTerm.setTextColor(menuOtherTextColor)
159 parentTerm.setBackgroundColor(menuOtherBgColor)
160 end
161 parentTerm.write(" " .. tProcesses[n].sTitle .. " ")
162 nCharCount = nCharCount + #tProcesses[n].sTitle + 2
163 end
164 if nCharCount > nSize then
165 parentTerm.setTextColor(menuOtherTextColor)
166 parentTerm.setBackgroundColor(menuOtherBgColor)
167 parentTerm.setCursorPos(nSize, 1)
168 parentTerm.write(">")
169 bScrollRight = true
170 else
171 bScrollRight = false
172 end
173
174 -- Put the cursor back where it should be
175 local tProcess = tProcesses[nCurrentProcess]
176 if tProcess then
177 tProcess.window.restoreCursor()
178 end
179 end
180end
181
182local function resizeWindows()
183 local windowY, windowHeight
184 if bShowMenu then
185 windowY = 2
186 windowHeight = h - 1
187 else
188 windowY = 1
189 windowHeight = h
190 end
191 for n = 1, #tProcesses do
192 local tProcess = tProcesses[n]
193 local x, y = tProcess.window.getCursorPos()
194 if y > windowHeight then
195 tProcess.window.scroll(y - windowHeight)
196 tProcess.window.setCursorPos(x, windowHeight)
197 end
198 tProcess.window.reposition(1, windowY, w, windowHeight)
199 end
200 bWindowsResized = true
201end
202
203local function setMenuVisible(bVis)
204 if bShowMenu ~= bVis then
205 bShowMenu = bVis
206 resizeWindows()
207 redrawMenu()
208 end
209end
210
211local multishell = {} --- @export
212
213--- Get the currently visible process. This will be the one selected on
214-- the tab bar.
215--
216-- Note, this is different to @{getCurrent}, which returns the process which is
217-- currently executing.
218--
219-- @treturn number The currently visible process's index.
220-- @see setFocus
221function multishell.getFocus()
222 return nCurrentProcess
223end
224
225--- Change the currently visible process.
226--
227-- @tparam number n The process index to switch to.
228-- @treturn boolean If the process was changed successfully. This will
229-- return @{false} if there is no process with this id.
230-- @see getFocus
231function multishell.setFocus(n)
232 expect(1, n, "number")
233 if n >= 1 and n <= #tProcesses then
234 selectProcess(n)
235 redrawMenu()
236 return true
237 end
238 return false
239end
240
241--- Get the title of the given tab.
242--
243-- This starts as the name of the program, but may be changed using
244-- @{multishell.setTitle}.
245-- @tparam number n The process index.
246-- @treturn string|nil The current process title, or @{nil} if the
247-- process doesn't exist.
248function multishell.getTitle(n)
249 expect(1, n, "number")
250 if n >= 1 and n <= #tProcesses then
251 return tProcesses[n].sTitle
252 end
253 return nil
254end
255
256--- Set the title of the given process.
257--
258-- @tparam number n The process index.
259-- @tparam string title The new process title.
260-- @see getTitle
261-- @usage Change the title of the current process
262--
263-- multishell.setTitle(multishell.getCurrent(), "Hello")
264function multishell.setTitle(n, title)
265 expect(1, n, "number")
266 expect(2, title, "string")
267 if n >= 1 and n <= #tProcesses then
268 setProcessTitle(n, title)
269 redrawMenu()
270 end
271end
272
273--- Get the index of the currently running process.
274--
275-- @treturn number The currently running process.
276function multishell.getCurrent()
277 return nRunningProcess
278end
279
280--- Start a new process, with the given environment, program and arguments.
281--
282-- The returned process index is not constant over the program's run. It can be
283-- safely used immediately after launching (for instance, to update the title or
284-- switch to that tab). However, after your program has yielded, it may no
285-- longer be correct.
286--
287-- @tparam table tProgramEnv The environment to load the path under.
288-- @tparam string sProgramPath The path to the program to run.
289-- @param ... Additional arguments to pass to the program.
290-- @treturn number The index of the created process.
291-- @see os.run
292-- @usage Run the "hello" program, and set its title to "Hello!"
293-- local id = multishell.launch({}, "/rom/programs/fun/hello.lua")
294-- multishell.setTitle(id, "Hello!")
295function multishell.launch(tProgramEnv, sProgramPath, ...)
296 expect(1, tProgramEnv, "table")
297 expect(2, sProgramPath, "string")
298 local previousTerm = term.current()
299 setMenuVisible(#tProcesses + 1 >= 2)
300 local nResult = launchProcess(false, tProgramEnv, sProgramPath, ...)
301 redrawMenu()
302 term.redirect(previousTerm)
303 return nResult
304end
305
306--- Get the number of processes within this multishell.
307--
308-- @treturn number The number of processes.
309function multishell.getCount()
310 return #tProcesses
311end
312
313-- Begin
314parentTerm.clear()
315setMenuVisible(false)
316launchProcess(true, {
317 ["shell"] = shell,
318 ["multishell"] = multishell,
319}, "/rom/programs/shell.lua")
320
321local switching = false
322local switches = 0
323
324-- Run processes
325while #tProcesses > 0 do
326 -- Get the event
327 local tEventData = table.pack(os.pullEventRaw())
328 local sEvent = tEventData[1]
329 if sEvent == "term_resize" then
330 -- Resize event
331 w, h = parentTerm.getSize()
332 resizeWindows()
333 redrawMenu()
334
335 elseif sEvent == "char" or sEvent == "key" or sEvent == "key_up" or sEvent == "paste" or sEvent == "terminate" then
336 -- Keyboard event
337
338 local passthrough = true
339
340 if sEvent == 'key' then
341 if switching then
342 if tEventData[2] == keys.left then
343 passthrough = false
344
345 if nCurrentProcess == 1 then
346 selectProcess(#tProcesses)
347 else
348 selectProcess(nCurrentProcess - 1)
349 end
350
351 redrawMenu()
352 switches = switches + 1
353 elseif tEventData[2] == keys.right then
354 passthrough = false
355
356 if nCurrentProcess == #tProcesses then
357 selectProcess(1)
358 else
359 selectProcess(nCurrentProcess + 1)
360 end
361
362 redrawMenu()
363 switches = switches + 1
364 else
365 passthrough = false
366 switching = false
367
368 resumeProcess(nCurrentProcess, 'key', keys.ctrl)
369
370 if not cullProcess(nCurrentProcess) then
371 resumeProcess(nCurrentProcess, table.unpack(tEventData, 1, tEventData.n))
372
373 if cullProcess(nCurrentProcess) then
374 setMenuVisible(#tProcesses >= 2)
375 redrawMenu()
376 end
377 else
378 setMenuVisible(#tProcesses >= 2)
379 redrawMenu()
380 end
381 end
382 elseif tEventData[2] == keys.leftCtrl then
383 passthrough = false
384 switching = true
385 switches = 0
386 end
387 elseif sEvent == 'key_up' then
388 if tEventData[2] == keys.leftCtrl and switching then
389 switching = false
390
391 if switches == 0 then
392 resumeProcess(nCurrentProcess, 'key', keys.leftCtrl)
393 passthrough = not cullProcess(nCurrentProcess)
394
395 if not passthrough then
396 setMenuVisible(#tProcesses > 2)
397 redrawMenu()
398 end
399 else
400 passthrough = false
401 end
402 end
403 end
404
405 if passthrough then
406 -- Passthrough to current process
407 resumeProcess(nCurrentProcess, table.unpack(tEventData, 1, tEventData.n))
408 if cullProcess(nCurrentProcess) then
409 setMenuVisible(#tProcesses >= 2)
410 redrawMenu()
411 end
412 end
413
414 elseif sEvent == "mouse_click" then
415 -- Click event
416 local button, x, y = tEventData[2], tEventData[3], tEventData[4]
417 if bShowMenu and y == 1 then
418 -- Switch process
419 if x == 1 and nScrollPos ~= 1 then
420 nScrollPos = nScrollPos - 1
421 redrawMenu()
422 elseif bScrollRight and x == term.getSize() then
423 nScrollPos = nScrollPos + 1
424 redrawMenu()
425 else
426 local tabStart = 1
427 if nScrollPos ~= 1 then
428 tabStart = 2
429 end
430 for n = nScrollPos, #tProcesses do
431 local tabEnd = tabStart + #tProcesses[n].sTitle + 1
432 if x >= tabStart and x <= tabEnd then
433 selectProcess(n)
434 redrawMenu()
435 break
436 end
437 tabStart = tabEnd + 1
438 end
439 end
440 else
441 -- Passthrough to current process
442 resumeProcess(nCurrentProcess, sEvent, button, x, bShowMenu and y - 1 or y)
443 if cullProcess(nCurrentProcess) then
444 setMenuVisible(#tProcesses >= 2)
445 redrawMenu()
446 end
447 end
448
449 elseif sEvent == "mouse_drag" or sEvent == "mouse_up" or sEvent == "mouse_scroll" then
450 -- Other mouse event
451 local p1, x, y = tEventData[2], tEventData[3], tEventData[4]
452 if bShowMenu and sEvent == "mouse_scroll" and y == 1 then
453 if p1 == -1 and nScrollPos ~= 1 then
454 nScrollPos = nScrollPos - 1
455 redrawMenu()
456 elseif bScrollRight and p1 == 1 then
457 nScrollPos = nScrollPos + 1
458 redrawMenu()
459 end
460 elseif not (bShowMenu and y == 1) then
461 -- Passthrough to current process
462 resumeProcess(nCurrentProcess, sEvent, p1, x, bShowMenu and y - 1 or y)
463 if cullProcess(nCurrentProcess) then
464 setMenuVisible(#tProcesses >= 2)
465 redrawMenu()
466 end
467 end
468
469 else
470 -- Other event
471 -- Passthrough to all processes
472 local nLimit = #tProcesses -- Storing this ensures any new things spawned don't get the event
473 for n = 1, nLimit do
474 resumeProcess(n, table.unpack(tEventData, 1, tEventData.n))
475 end
476 if cullProcesses() then
477 setMenuVisible(#tProcesses >= 2)
478 redrawMenu()
479 end
480 end
481
482 if bWindowsResized then
483 -- Pass term_resize to all processes
484 local nLimit = #tProcesses -- Storing this ensures any new things spawned don't get the event
485 for n = 1, nLimit do
486 resumeProcess(n, "term_resize")
487 end
488 bWindowsResized = false
489 if cullProcesses() then
490 setMenuVisible(#tProcesses >= 2)
491 redrawMenu()
492 end
493 end
494end
495
496-- Shutdown
497term.redirect(parentTerm)