mbs-multishell/multishell.lua

by hugeblank
703 days ago
COPY
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)