上传文件至 /

This commit is contained in:
2026-01-30 23:35:13 +08:00
commit 87972f5bf9

704
multishell.lua Normal file
View File

@@ -0,0 +1,704 @@
-- SPDX-FileCopyrightText: 2017 Daniel Ratcliffe
--
-- SPDX-License-Identifier: LicenseRef-CCPL
--- Multishell allows multiple programs to be run at the same time.
--
-- When multiple programs are running, it displays a tab bar at the top of the
-- screen, which allows you to switch between programs. New programs can be
-- launched using the `fg` or `bg` programs, or using the [`shell.openTab`] and
-- [`multishell.launch`] functions.
--
-- Each process is identified by its ID, which corresponds to its position in
-- the tab list. As tabs may be opened and closed, this ID is _not_ constant
-- over a program's run. As such, be careful not to use stale IDs.
--
-- As with [`shell`], [`multishell`] is not a "true" API. Instead, it is a
-- standard program, which launches a shell and injects its API into the shell's
-- environment. This API is not available in the global environment, and so is
-- not available to [APIs][`os.loadAPI`].
--
-- @module[module] multishell
-- @since 1.6
local expect = dofile("rom/modules/main/cc/expect.lua").expect
-- Setup process switching
local parentTerm = term.current()
local w, h = parentTerm.getSize()
local tProcesses = {}
local nCurrentProcess = nil
local nRunningProcess = nil
local bShowMenu = false
local bWindowsResized = false
local nScrollPos = 1
local bScrollRight = false
-- Helper: count visible (non-hidden) processes
local function setVisibleProcessCount()
local count = 0
for _, p in ipairs(tProcesses) do
if not p.bHideTab then
count = count + 1
end
end
return count
end
local function selectProcess(n)
if nCurrentProcess ~= n then
if nCurrentProcess then
local tOldProcess = tProcesses[nCurrentProcess]
tOldProcess.window.setVisible(false)
end
nCurrentProcess = n
if nCurrentProcess then
local tNewProcess = tProcesses[nCurrentProcess]
tNewProcess.window.setVisible(true)
tNewProcess.bInteracted = true
end
end
end
local function setProcessTitle(n, sTitle)
tProcesses[n].sTitle = sTitle
end
local function resumeProcess(nProcess, sEvent, ...)
local tProcess = tProcesses[nProcess]
local sFilter = tProcess.sFilter
if sFilter == nil or sFilter == sEvent or sEvent == "terminate" then
local nPreviousProcess = nRunningProcess
nRunningProcess = nProcess
term.redirect(tProcess.terminal)
local ok, result = coroutine.resume(tProcess.co, sEvent, ...)
tProcess.terminal = term.current()
if ok then
tProcess.sFilter = result
else
printError(result)
end
nRunningProcess = nPreviousProcess
end
end
local function launchProcess(bFocus, tProgramEnv, sProgramPath, ...)
local tProgramArgs = table.pack(...)
local nProcess = #tProcesses + 1
local tProcess = {}
tProcess.sProgramPath = sProgramPath
tProcess.env = tProgramEnv
tProcess.args = tProgramArgs
tProcess.sTitle = fs.getName(sProgramPath)
if bShowMenu then
tProcess.window = window.create(parentTerm, 1, 2, w, h - 1, false)
else
tProcess.window = window.create(parentTerm, 1, 1, w, h, false)
end
-- Restrict the public view of the window to normal redirect functions.
tProcess.terminal = {}
for k in pairs(term.native()) do tProcess.terminal[k] = tProcess.window[k] end
tProcess.co = coroutine.create(function()
os.run(tProgramEnv, sProgramPath, table.unpack(tProgramArgs, 1, tProgramArgs.n))
if not tProcess.bInteracted then
term.setCursorBlink(false)
print("Press any key to continue")
os.pullEvent("char")
end
end)
tProcess.sFilter = nil
tProcess.bInteracted = false
tProcess.bHideTab = false
tProcesses[nProcess] = tProcess
if bFocus then
selectProcess(nProcess)
end
resumeProcess(nProcess)
return nProcess
end
local function cullProcess(nProcess)
local tProcess = tProcesses[nProcess]
if coroutine.status(tProcess.co) == "dead" then
if nCurrentProcess == nProcess then
selectProcess(nil)
end
table.remove(tProcesses, nProcess)
if nCurrentProcess == nil then
if nProcess > 1 then
selectProcess(nProcess - 1)
elseif #tProcesses > 0 then
selectProcess(1)
end
end
if nScrollPos ~= 1 then
nScrollPos = nScrollPos - 1
end
return true
end
return false
end
local function cullProcesses()
local culled = false
for n = #tProcesses, 1, -1 do
culled = culled or cullProcess(n)
end
return culled
end
-- Setup the main menu
local menuMainTextColor, menuMainBgColor, menuOtherTextColor, menuOtherBgColor
if parentTerm.isColor() then
menuMainTextColor, menuMainBgColor = colors.yellow, colors.black
menuOtherTextColor, menuOtherBgColor = colors.black, colors.gray
else
menuMainTextColor, menuMainBgColor = colors.white, colors.black
menuOtherTextColor, menuOtherBgColor = colors.black, colors.gray
end
local function redrawMenu()
if not bShowMenu then return end
-- Build list of visible processes
local visibleProcesses = {}
for i, p in ipairs(tProcesses) do
if not p.bHideTab then
table.insert(visibleProcesses, { index = i, proc = p })
end
end
parentTerm.setCursorPos(1, 1)
parentTerm.setBackgroundColor(menuOtherBgColor)
parentTerm.clearLine()
local nCharCount = 0
local nSize = parentTerm.getSize()
local totalVisible = #visibleProcesses
-- Adjust scroll if out of bounds
if nScrollPos < 1 then nScrollPos = 1 end
if nScrollPos > totalVisible then nScrollPos = math.max(1, totalVisible) end
if nScrollPos > 1 then
parentTerm.setTextColor(menuOtherTextColor)
parentTerm.setBackgroundColor(menuOtherBgColor)
parentTerm.write("<")
nCharCount = 1
end
for i = nScrollPos, totalVisible do
local item = visibleProcesses[i]
local isCurrent = (nCurrentProcess == item.index)
if isCurrent then
parentTerm.setTextColor(menuMainTextColor)
parentTerm.setBackgroundColor(menuMainBgColor)
else
parentTerm.setTextColor(menuOtherTextColor)
parentTerm.setBackgroundColor(menuOtherBgColor)
end
parentTerm.write(" " .. item.proc.sTitle .. " ")
nCharCount = nCharCount + #item.proc.sTitle + 2
end
if nCharCount > nSize then
parentTerm.setTextColor(menuOtherTextColor)
parentTerm.setBackgroundColor(menuOtherBgColor)
parentTerm.setCursorPos(nSize, 1)
parentTerm.write(">")
bScrollRight = true
else
bScrollRight = false
end
-- Restore cursor for current process
if nCurrentProcess and tProcesses[nCurrentProcess] then
tProcesses[nCurrentProcess].window.restoreCursor()
end
end
local function resizeWindows()
local windowY, windowHeight
if bShowMenu then
windowY = 2
windowHeight = h - 1
else
windowY = 1
windowHeight = h
end
for n = 1, #tProcesses do
local tProcess = tProcesses[n]
local x, y = tProcess.window.getCursorPos()
if y > windowHeight then
tProcess.window.scroll(y - windowHeight)
tProcess.window.setCursorPos(x, windowHeight)
end
tProcess.window.reposition(1, windowY, w, windowHeight)
end
bWindowsResized = true
end
local function setMenuVisible(bVis)
if bShowMenu ~= bVis then
bShowMenu = bVis
resizeWindows()
redrawMenu()
end
end
local multishell = {} --- @export
--- Get the currently visible process. This will be the one selected on
-- the tab bar.
--
-- Note, this is different to [`getCurrent`], which returns the process which is
-- currently executing.
--
-- @treturn number The currently visible process's index.
-- @see setFocus
function multishell.getFocus()
return nCurrentProcess
end
--- Change the currently visible process.
--
-- @tparam number n The process index to switch to.
-- @treturn boolean If the process was changed successfully. This will
-- return [`false`] if there is no process with this id.
-- @see getFocus
function multishell.setFocus(n)
expect(1, n, "number")
if n >= 1 and n <= #tProcesses then
selectProcess(n)
redrawMenu()
return true
end
return false
end
--- Get the title of the given tab.
--
-- This starts as the name of the program, but may be changed using
-- [`multishell.setTitle`].
-- @tparam number n The process index.
-- @treturn string|nil The current process title, or [`nil`] if the
-- process doesn't exist.
function multishell.getTitle(n)
expect(1, n, "number")
if n >= 1 and n <= #tProcesses then
return tProcesses[n].sTitle
end
return nil
end
--- Set the title of the given process.
--
-- @tparam number n The process index.
-- @tparam string title The new process title.
-- @see getTitle
-- @usage Change the title of the current process
--
-- multishell.setTitle(multishell.getCurrent(), "Hello")
function multishell.setTitle(n, title)
expect(1, n, "number")
expect(2, title, "string")
if n >= 1 and n <= #tProcesses then
setProcessTitle(n, title)
redrawMenu()
end
end
--- Get the index of the currently running process.
--
-- @treturn number The currently running process.
function multishell.getCurrent()
return nRunningProcess
end
--- Start a new process, with the given environment, program and arguments.
--
-- The returned process index is not constant over the program's run. It can be
-- safely used immediately after launching (for instance, to update the title or
-- switch to that tab). However, after your program has yielded, it may no
-- longer be correct.
--
-- @tparam table tProgramEnv The environment to load the path under.
-- @tparam string sProgramPath The path to the program to run.
-- @param ... Additional arguments to pass to the program.
-- @treturn number The index of the created process.
-- @see os.run
-- @usage Run the "hello" program, and set its title to "Hello!"
--
-- local id = multishell.launch({}, "/rom/programs/fun/hello.lua")
-- multishell.setTitle(id, "Hello!")
function multishell.launch(tProgramEnv, sProgramPath, ...)
expect(1, tProgramEnv, "table")
expect(2, sProgramPath, "string")
local previousTerm = term.current()
local nResult = launchProcess(false, tProgramEnv, sProgramPath, ...)
redrawMenu()
setMenuVisible(setVisibleProcessCount() >= 2)
term.redirect(previousTerm)
return nResult
end
--- Get the number of processes within this multishell.
--
-- @treturn number The number of processes.
function multishell.getCount()
return #tProcesses
end
-- - Stop a running process by its ID.
-- @tparam number n The process index to stop.
-- @treturn boolean Whether the process existed and was stopped.
function multishell.stop(n)
local previousTerm1 = term.current()
expect(1, n, "number")
if n < 1 or n > #tProcesses then
return false
end
-- Send terminate event
resumeProcess(n, "terminate")
-- Force cleanup if dead, or mark as interacted to skip prompt
local tProcess = tProcesses[n]
if coroutine.status(tProcess.co) == "dead" then
cullProcess(n)
else
tProcess.bInteracted = true -- skip "Press any key"
end
setMenuVisible(setVisibleProcessCount() >= 2)
redrawMenu()
term.redirect(previousTerm1)
return true
end
--- Get detailed information about a process.
--
-- @tparam number n The process index.
-- @treturn table|nil A table containing process info, or nil if invalid.
-- Fields:
-- - id: process index (same as n)
-- - type: "Path" or "Function"
-- - path: program path (if type == "Path")
-- - func: function reference (if type == "Function")
-- - env: environment table
-- - args: arguments table
-- - autoClose: boolean
-- - hideTab: boolean
-- - title: current title
-- - status: coroutine status ("running", "suspended", "dead")
function multishell.getProcessInfo(n)
expect(1, n, "number")
if n < 1 or n > #tProcesses then
return nil
end
local p = tProcesses[n]
local info = {
id = n,
hideTab = p.bHideTab,
autoClose = p.bInteracted and not (p.co and coroutine.status(p.co) == "suspended" and not p.bInteracted), -- approximate
title = p.sTitle,
status = p.co and coroutine.status(p.co) or "dead",
env = p.env or {}, -- 注意:原代码未保存 env需额外记录见下方说明
args = p.args or {}, -- 同上
}
-- 判断是 Path 还是 Function
if p.sProgramPath then
info.type = "Path"
info.path = p.sProgramPath
elseif p.fFunction then
info.type = "Function"
info.func = p.fFunction
else
info.type = "Unknown"
end
return info
end
--- Launch a new process with advanced options.
--
-- Accepts either a program path or a function to run.
--
-- @tparam table options A table containing launch options:
-- - `path` (string): Path to a program to run (mutually exclusive with `func`).
-- - `func` (function): A function to execute directly (mutually exclusive with `path`).
-- - `args` (table): Arguments to pass to the program/function.
-- - `env` (table): Environment table (default: `{}`).
-- - `hideTab` (boolean): If true, do not show in tab bar (default: false).
-- - `autoClose` (boolean): If true, close immediately after finish without "Press any key" (default: false).
-- @treturn number The index of the created process.
function multishell.newlaunch(options)
expect(1, options, "table")
local sProgramPath = options.path
local fFunction = options.func
local tArgs = type(options.args) == "table" and options.args or {}
local tEnv = type(options.env) == "table" and options.env or {}
local bHideTab = options.hideTab == true
local bAutoClose = options.autoClose == true
if sProgramPath and fFunction then
error("Cannot specify both 'path' and 'func'", 2)
elseif not sProgramPath and not fFunction then
error("Must specify either 'path' or 'func'", 2)
end
if sProgramPath then
expect(1, sProgramPath, "string")
else
expect(1, fFunction, "function")
end
local previousTerm = term.current()
local sTitle = sProgramPath and fs.getName(sProgramPath) or "function"
local nProcess = #tProcesses + 1
local tProcess = {}
tProcess.sProgramPath = sProgramPath
tProcess.fFunction = fFunction
tProcess.env = tEnv
tProcess.args = tArgs
tProcess.bAutoClose = bAutoClose -- 显式保存,便于查询
-- Create window
local visibleCountBefore = setVisibleProcessCount()
local willBeVisible = not bHideTab
local showMenuNow = (visibleCountBefore + (willBeVisible and 1 or 0)) >= 2
if showMenuNow then
tProcess.window = window.create(parentTerm, 1, 2, w, h - 1, false)
else
tProcess.window = window.create(parentTerm, 1, 1, w, h, false)
end
-- Terminal proxy
tProcess.terminal = {}
for k in pairs(term.native()) do
tProcess.terminal[k] = tProcess.window[k]
end
-- Coroutine
if sProgramPath then
tProcess.co = coroutine.create(function()
os.run(tEnv, sProgramPath, table.unpack(tArgs))
if not bAutoClose and not tProcess.bInteracted then
term.setCursorBlink(false)
print("Press any key to continue")
os.pullEvent("char")
end
end)
else
tProcess.co = coroutine.create(function()
local success, err = pcall(function()
fFunction(table.unpack(tArgs))
end)
if not success then
printError(err)
end
if not bAutoClose and not tProcess.bInteracted then
term.setCursorBlink(false)
print("Press any key to continue")
os.pullEvent("char")
end
end)
end
tProcess.sTitle = sTitle
tProcess.sFilter = nil
tProcess.bInteracted = bAutoClose -- 保持原逻辑
tProcess.bHideTab = bHideTab
tProcesses[nProcess] = tProcess
setMenuVisible(setVisibleProcessCount() >= 2)
resumeProcess(nProcess)
redrawMenu()
term.redirect(previousTerm)
return nProcess
end
--- Set whether a process's tab is hidden in the tab bar.
--
-- @tparam number n The process index.
-- @tparam boolean hide Whether to hide the tab.
-- @treturn boolean True if successful, false if process doesn't exist.
function multishell.setHideTab(n, hide)
expect(1, n, "number")
expect(2, hide, "boolean")
if n < 1 or n > #tProcesses then
return false
end
local p = tProcesses[n]
local wasHidden = p.bHideTab
p.bHideTab = hide
-- If visibility changed, update menu visibility logic
if wasHidden ~= hide then
setMenuVisible(setVisibleProcessCount() >= 2)
redrawMenu()
end
return true
end
--- Get information about all currently running processes.
--
-- @treturn table A list of process info tables, each in the same format as
-- [`multishell.getProcessInfo`]. The list is ordered by process ID (1 to N).
-- @see getProcessInfo
function multishell.listProcesses()
local result = {}
for i = 1, #tProcesses do
-- Only include valid (non-nil) processes
if tProcesses[i] then
table.insert(result, multishell.getProcessInfo(i))
end
end
return result
end
-- Begin
parentTerm.clear()
setMenuVisible(false)
launchProcess(true, {
["shell"] = shell,
["multishell"] = multishell,
}, "/rom/programs/shell.lua")
-- Run processes
while #tProcesses > 0 do
-- Get the event
local tEventData = table.pack(os.pullEventRaw())
local sEvent = tEventData[1]
if sEvent == "term_resize" then
-- Resize event
w, h = parentTerm.getSize()
resizeWindows()
redrawMenu()
elseif sEvent == "char" or sEvent == "key" or sEvent == "key_up" or sEvent == "paste" or sEvent == "terminate" or sEvent == "file_transfer" then
-- Basic input, just passthrough to current process
if nCurrentProcess then
resumeProcess(nCurrentProcess, table.unpack(tEventData, 1, tEventData.n))
if cullProcess(nCurrentProcess) then
setMenuVisible(setVisibleProcessCount() >= 2)
redrawMenu()
end
end
elseif sEvent == "mouse_click" then
-- Click event
local button, x, y = tEventData[2], tEventData[3], tEventData[4]
if bShowMenu and y == 1 then
-- Switch process via tab bar
local visibleProcesses = {}
for i, p in ipairs(tProcesses) do
if not p.bHideTab then
table.insert(visibleProcesses, { index = i, proc = p })
end
end
if x == 1 and nScrollPos ~= 1 then
nScrollPos = nScrollPos - 1
redrawMenu()
elseif bScrollRight and x == term.getSize() then
nScrollPos = nScrollPos + 1
redrawMenu()
else
local tabStart = 1
if nScrollPos ~= 1 then
tabStart = 2
end
for i = nScrollPos, #visibleProcesses do
local item = visibleProcesses[i]
local tabEnd = tabStart + #item.proc.sTitle + 1
if x >= tabStart and x <= tabEnd then
selectProcess(item.index)
redrawMenu()
break
end
tabStart = tabEnd + 1
end
end
else
-- Passthrough to current process
if nCurrentProcess then
resumeProcess(nCurrentProcess, sEvent, button, x, bShowMenu and y - 1 or y)
if cullProcess(nCurrentProcess) then
setMenuVisible(setVisibleProcessCount() >= 2)
redrawMenu()
end
end
end
elseif sEvent == "mouse_drag" or sEvent == "mouse_up" or sEvent == "mouse_scroll" then
-- Other mouse event
local p1, x, y = tEventData[2], tEventData[3], tEventData[4]
if bShowMenu and sEvent == "mouse_scroll" and y == 1 then
if p1 == -1 and nScrollPos ~= 1 then
nScrollPos = nScrollPos - 1
redrawMenu()
elseif bScrollRight and p1 == 1 then
nScrollPos = nScrollPos + 1
redrawMenu()
end
elseif not (bShowMenu and y == 1) then
if nCurrentProcess then
resumeProcess(nCurrentProcess, sEvent, p1, x, bShowMenu and y - 1 or y)
if cullProcess(nCurrentProcess) then
setMenuVisible(setVisibleProcessCount() >= 2)
redrawMenu()
end
end
end
else
-- Other event: broadcast to all processes
local nLimit = #tProcesses
for n = 1, nLimit do
resumeProcess(n, table.unpack(tEventData, 1, tEventData.n))
end
if cullProcesses() then
setMenuVisible(setVisibleProcessCount() >= 2)
redrawMenu()
end
end
if bWindowsResized then
local nLimit = #tProcesses
for n = 1, nLimit do
resumeProcess(n, "term_resize")
end
bWindowsResized = false
if cullProcesses() then
setMenuVisible(setVisibleProcessCount() >= 2)
redrawMenu()
end
end
end
-- Shutdown
term.redirect(parentTerm)