commit 87972f5bf984dfcd17e394ce6ca8e7440cf54553 Author: xingluo Date: Fri Jan 30 23:35:13 2026 +0800 上传文件至 / diff --git a/multishell.lua b/multishell.lua new file mode 100644 index 0000000..ffd98f1 --- /dev/null +++ b/multishell.lua @@ -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) \ No newline at end of file