· 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
321-- Run processes
322while #tProcesses > 0 do
323 -- Get the event
324 local tEventData = table.pack(os.pullEventRaw())
325 local sEvent = tEventData[1]
326 if sEvent == "term_resize" then
327 -- Resize event
328 w, h = parentTerm.getSize()
329 resizeWindows()
330 redrawMenu()
331
332 elseif sEvent == "char" or sEvent == "key" or sEvent == "key_up" or sEvent == "paste" or sEvent == "terminate" then
333 -- Keyboard event
334 -- Passthrough to current process
335 resumeProcess(nCurrentProcess, table.unpack(tEventData, 1, tEventData.n))
336 if cullProcess(nCurrentProcess) then
337 setMenuVisible(#tProcesses >= 2)
338 redrawMenu()
339 end
340
341 elseif sEvent == "mouse_click" then
342 -- Click event
343 local button, x, y = tEventData[2], tEventData[3], tEventData[4]
344 if bShowMenu and y == 1 then
345 -- Switch process
346 if x == 1 and nScrollPos ~= 1 then
347 nScrollPos = nScrollPos - 1
348 redrawMenu()
349 elseif bScrollRight and x == term.getSize() then
350 nScrollPos = nScrollPos + 1
351 redrawMenu()
352 else
353 local tabStart = 1
354 if nScrollPos ~= 1 then
355 tabStart = 2
356 end
357 for n = nScrollPos, #tProcesses do
358 local tabEnd = tabStart + #tProcesses[n].sTitle + 1
359 if x >= tabStart and x <= tabEnd then
360 selectProcess(n)
361 redrawMenu()
362 break
363 end
364 tabStart = tabEnd + 1
365 end
366 end
367 else
368 -- Passthrough to current process
369 resumeProcess(nCurrentProcess, sEvent, button, x, bShowMenu and y - 1 or y)
370 if cullProcess(nCurrentProcess) then
371 setMenuVisible(#tProcesses >= 2)
372 redrawMenu()
373 end
374 end
375
376 elseif sEvent == "mouse_drag" or sEvent == "mouse_up" or sEvent == "mouse_scroll" then
377 -- Other mouse event
378 local p1, x, y = tEventData[2], tEventData[3], tEventData[4]
379 if bShowMenu and sEvent == "mouse_scroll" and y == 1 then
380 if p1 == -1 and nScrollPos ~= 1 then
381 nScrollPos = nScrollPos - 1
382 redrawMenu()
383 elseif bScrollRight and p1 == 1 then
384 nScrollPos = nScrollPos + 1
385 redrawMenu()
386 end
387 elseif not (bShowMenu and y == 1) then
388 -- Passthrough to current process
389 resumeProcess(nCurrentProcess, sEvent, p1, x, bShowMenu and y - 1 or y)
390 if cullProcess(nCurrentProcess) then
391 setMenuVisible(#tProcesses >= 2)
392 redrawMenu()
393 end
394 end
395
396 else
397 -- Other event
398 -- Passthrough to all processes
399 local nLimit = #tProcesses -- Storing this ensures any new things spawned don't get the event
400 for n = 1, nLimit do
401 resumeProcess(n, table.unpack(tEventData, 1, tEventData.n))
402 end
403 if cullProcesses() then
404 setMenuVisible(#tProcesses >= 2)
405 redrawMenu()
406 end
407 end
408
409 if bWindowsResized then
410 -- Pass term_resize to all processes
411 local nLimit = #tProcesses -- Storing this ensures any new things spawned don't get the event
412 for n = 1, nLimit do
413 resumeProcess(n, "term_resize")
414 end
415 bWindowsResized = false
416 if cullProcesses() then
417 setMenuVisible(#tProcesses >= 2)
418 redrawMenu()
419 end
420 end
421end
422
423-- Shutdown
424term.redirect(parentTerm)