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