diff --git a/.ci/helper_shellchecks.sh b/.ci/helper_shellchecks.sh index ea7e2fe79..bc70b1589 100755 --- a/.ci/helper_shellchecks.sh +++ b/.ci/helper_shellchecks.sh @@ -5,7 +5,7 @@ CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "${CI_DIR}/common.sh" # shellcheck disable=2016 -mapfile -t shellscript_locations < <({ git grep -lE '^#!(/usr)?/bin/(env )?(bash|sh)' && git submodule --quiet foreach '[ "$path" = "base" -o "$path" = "platform/android/luajit-launcher" ] || git grep -lE "^#!(/usr)?/bin/(env )?(bash|sh)" | sed "s|^|$path/|"' && git ls-files ./*.sh; } | sort | uniq) +mapfile -t shellscript_locations < <({ git grep -lE '^#!(/usr)?/bin/(env )?(bash|sh)' | sed "/^plugins\/terminal.koplugin\/shfm$/d" && git submodule --quiet foreach '[ "$path" = "base" -o "$path" = "platform/android/luajit-launcher" ] || git grep -lE "^#!(/usr)?/bin/(env )?(bash|sh)" | sed "s|^|$path/|"' && git ls-files ./*.sh; } | sort | uniq) SHELLSCRIPT_ERROR=0 SHFMT_OPTIONS="-i 4 -ci" diff --git a/frontend/ui/widget/inputdialog.lua b/frontend/ui/widget/inputdialog.lua index 1ddb378f6..35de6285b 100644 --- a/frontend/ui/widget/inputdialog.lua +++ b/frontend/ui/widget/inputdialog.lua @@ -128,6 +128,8 @@ local InputDialog = InputContainer:new{ input_type = nil, deny_keyboard_hiding = false, -- don't hide keyboard on tap outside enter_callback = nil, + strike_callback = nil, -- call this on every keystroke (used by Terminal plugin's TermInputText) + inputtext_class = InputText, -- (Terminal plugin provides TermInputText) readonly = false, -- don't allow editing, will not show keyboard allow_newline = false, -- allow entering new lines (this disables any enter_callback) cursor_at_end = true, -- starts with cursor at end of text, ready for appending @@ -279,7 +281,7 @@ function InputDialog:init() if not self.text_height or self.fullscreen then -- We need to find the best height to avoid screen overflow -- Create a dummy input widget to get some metrics - local input_widget = InputText:new{ + local input_widget = self.inputtext_class:new{ text = self.fullscreen and "-" or self.input, input_type = self.input_type, face = self.input_face, @@ -330,7 +332,7 @@ function InputDialog:init() -- (will work in case of re-init as these are saved by onClose() self._top_line_num, self._charpos = self.view_pos_callback() end - self._input_widget = InputText:new{ + self._input_widget = self.inputtext_class:new{ text = self.input, hint = self.input_hint, face = self.input_face, @@ -356,6 +358,7 @@ function InputDialog:init() end end end, + strike_callback = self.strike_callback, edit_callback = self._buttons_edit_callback, -- nil if no Save/Close buttons scroll_callback = self._buttons_scroll_callback, -- nil if no Nav or Scroll buttons scroll = true, diff --git a/frontend/ui/widget/keyvaluepage.lua b/frontend/ui/widget/keyvaluepage.lua index 5623b7784..61d73e5c4 100644 --- a/frontend/ui/widget/keyvaluepage.lua +++ b/frontend/ui/widget/keyvaluepage.lua @@ -61,6 +61,7 @@ local KeyValueItem = InputContainer:new{ -- "right": only align right if value overflow 1/2 width -- "right_always": align value right even when small and -- only key overflows 1/2 width + close_callback = nil, } function KeyValueItem:init() @@ -650,6 +651,9 @@ end function KeyValuePage:onClose() UIManager:close(self) + if self.close_callback then + self.close_callback() + end return true end diff --git a/frontend/ui/widget/virtualkeyboard.lua b/frontend/ui/widget/virtualkeyboard.lua index f95582ed6..20c877890 100644 --- a/frontend/ui/widget/virtualkeyboard.lua +++ b/frontend/ui/widget/virtualkeyboard.lua @@ -131,8 +131,16 @@ function VirtualKey:init() end elseif self.label == "↑" then self.callback = function() self.keyboard:upLine() end + self.hold_callback = function() + self.ignore_key_release = true + self.keyboard:scrollUp() + end elseif self.label == "↓" then self.callback = function() self.keyboard:downLine() end + self.hold_callback = function() + self.ignore_key_release = true + self.keyboard:scrollDown() + end else self.callback = function () self.keyboard:addChar(self.key) end self.hold_callback = function() @@ -1033,10 +1041,18 @@ function VirtualKeyboard:upLine() self.inputbox:upLine() end +function VirtualKeyboard:scrollUp() + self.inputbox:scrollUp() +end + function VirtualKeyboard:downLine() self.inputbox:downLine() end +function VirtualKeyboard:scrollDown() + self.inputbox:scrollDown() +end + function VirtualKeyboard:clear() logger.dbg("clear input") self.inputbox:clear() diff --git a/plugins/terminal.koplugin/_meta.lua b/plugins/terminal.koplugin/_meta.lua index e033b7614..49e9587c4 100644 --- a/plugins/terminal.koplugin/_meta.lua +++ b/plugins/terminal.koplugin/_meta.lua @@ -1,6 +1,6 @@ local _ = require("gettext") return { name = "terminal", - fullname = _("Terminal"), - description = _([[Executes simple commands and shows their output.]]), + fullname = _("Terminal emulator"), + description = _([[KOReader's terminal emulator]]), } diff --git a/plugins/terminal.koplugin/aliases.lua b/plugins/terminal.koplugin/aliases.lua new file mode 100644 index 000000000..8b4accf05 --- /dev/null +++ b/plugins/terminal.koplugin/aliases.lua @@ -0,0 +1,169 @@ +local KeyValuePage = require("ui/widget/keyvaluepage") +local InfoMessage = require("ui/widget/infomessage") +local MultiInputDialog = require("ui/widget/multiinputdialog") +local UIManager = require("ui/uimanager") +local _ = require("gettext") +local util = require("util") +local T = require("ffi/util").template + +local Aliases = { + filename = "", + close_callback = nil, + parent = nil, + + alias_kv = nil, + kv_pairs = {}, +} + +function Aliases:show(filename, close_callback, parent) + self.filename = filename + self.close_callback = close_callback + self.parent = parent + self:load() + + self.alias_kv = KeyValuePage:new{ + title = "Aliases (Shortcuts)", + kv_pairs = self.kv_pairs, + close_callback = self.close_callback, + } + UIManager:show(self.alias_kv) +end + +function Aliases:updateKeyValues() + self.alias_kv.kv_pairs = self.kv_pairs + UIManager:close(self.alias_kv) + self.alias_kv = KeyValuePage:new{ + title = "Aliases (Shortcuts)", + kv_pairs = self.kv_pairs, + close_callback = self.close_callback, + } + UIManager:show(self.alias_kv) +end + +function Aliases:load() + local file = io.open(self.filename, "r") + self.kv_pairs = {} + if file then + for line in file:lines() do + line = line:gsub("^ *alias *", "") -- drop alias + local dummy, separator = line:find("^[%a%d][%a%d-_]*%=") -- find separator + if line ~= "" and line:sub(1, 1) ~= "#" and separator then + local alias_name = line:sub(1, separator - 1) + local alias_command = line:sub(separator + 1):gsub("\"", "") + table.insert(self.kv_pairs, {alias_name, alias_command, + callback = function() self.editAlias(self, alias_name, alias_command) end + }) + end + end + file:close() + table.sort(self.kv_pairs, function(a, b) return a[1] < b[1] end) + end + + table.insert(self.kv_pairs, 1, + { _("Create a new alias"), "", callback = function() self.editAlias(self, "", "") end}) + table.insert(self.kv_pairs, 2, "---") +end + +function Aliases:save() + local file = io.open(self.filename .. ".new", "w") + if not file then + UIManager:show(InfoMessage:new{ + text = T(_("Terminal emulator: error saving: %1"), self.filename) + }) + end + file:write("# Aliases generated by terminal emulator\n\n") + for i = 3, #self.kv_pairs do + file:write("alias " .. self.kv_pairs[i][1] .. "=\"" .. self.kv_pairs[i][2] .. "\"\n") + end + file:close() + os.remove(self.filename) + os.rename(self.filename .. ".new", self.filename) +end + +function Aliases:editAlias(alias_name, alias_command) + local alias_input + alias_input = MultiInputDialog:new{ + title = _("Edit alias"), + fields = { + { + description = _("Alias name:"), + text = alias_name, + }, + { + description = _("Alias command:"), + text = alias_command, + }, + }, + buttons = { + { + { + text = _("Cancel"), + callback = function() + UIManager:close(alias_input) + end + }, + { + text = _("Delete"), + callback = function() + UIManager:close(alias_input) + for i, v in pairs(self.kv_pairs) do + if v[1] == alias_name then + table.remove(self.kv_pairs, i) + self.parent:transmit("unalias " .. alias_name .."\n") + end + end + self:save() + self:updateKeyValues() + end + }, + { + text = _("Save"), + callback = function() + local fields = MultiInputDialog:getFields() + local name = fields[1] and util.trim(fields[1]) + local value = fields[2] and util.trim(fields[2]) + if name ~= "" and value ~= "" then + UIManager:close(alias_input) + for i, v in pairs(self.kv_pairs) do + if v[1] == alias_name then + table.remove(self.kv_pairs, i) + self.parent:transmit("unalias " .. alias_name .."\n") + end + end + self.parent:transmit("alias " .. name .. "='" .. value .. "'\n") + table.insert(self.kv_pairs, {name, value, + callback = function() + self.editAlias(self, name, value) + end + }) + table.remove(self.kv_pairs, 2) + table.remove(self.kv_pairs, 1) + table.sort(self.kv_pairs, function(a, b) return a[1] < b[1] end) + table.insert(self.kv_pairs, 1, { _("Create a new alias"), "", + callback = function() self:editAlias(self, "", "") end }) + table.insert(self.kv_pairs, 2, "---") + self:save() + self:updateKeyValues() + end + end + }, + { + text = _("Execute"), + callback = function() + local fields = MultiInputDialog:getFields() + local value = fields[2] and util.trim(fields[2]) + if value ~= "" then + UIManager:close(alias_input) + self.alias_kv:onClose() + self.parent:transmit(value .. "\n") + end + end + }, + }, + }, + } + UIManager:show(alias_input) + alias_input:onShowKeyboard() +end + +return Aliases diff --git a/plugins/terminal.koplugin/main.lua b/plugins/terminal.koplugin/main.lua index bef899573..9c30fe849 100644 --- a/plugins/terminal.koplugin/main.lua +++ b/plugins/terminal.koplugin/main.lua @@ -1,501 +1,567 @@ -local ButtonDialog = require("ui/widget/buttondialog") -local CenterContainer = require("ui/widget/container/centercontainer") -local DataStorage = require("datastorage") +--[[ +This plugin provides a terminal emulator (VT52 (+some ANSI)) +]] + +local Device = require("device") + +-- grantpt and friends are necessary (introduced on Android in API 21). +-- So sorry for the Tolinos with (Android 4.4.x). +-- Maybe https://f-droid.org/de/packages/jackpal.androidterm/ could be an alternative then. +if Device:isAndroid() and Device.firmware_rev < 21 then + return +end + +local Aliases = require("aliases") local Dispatcher = require("dispatcher") +local DataStorage = require("datastorage") local Font = require("ui/font") local InfoMessage = require("ui/widget/infomessage") local InputDialog = require("ui/widget/inputdialog") -local LuaSettings = require("luasettings") -local Menu = require("ui/widget/menu") -local TextViewer = require("ui/widget/textviewer") -local Trapper = require("ui/trapper") +local MultiConfirmBox = require("ui/widget/multiconfirmbox") +local ScrollTextWidget = require("ui/widget/scrolltextwidget") +local SpinWidget = require("ui/widget/spinwidget") local UIManager = require("ui/uimanager") local WidgetContainer = require("ui/widget/container/widgetcontainer") +local TermInputText = require("terminputtext") +local TextWidget = require("ui/widget/textwidget") +local bit = require("bit") +local lfs = require("libs/libkoreader-lfs") local logger = require("logger") -local util = require("ffi/util") local _ = require("gettext") -local N_ = _.ngettext -local Screen = require("device").screen -local T = util.template +local T = require("ffi/util").template + +local ffi = require("ffi") +local C = ffi.C + +-- for terminal emulator +ffi.cdef[[ +static const int SIGTERM = 15; + +int grantpt(int fd) __attribute__((nothrow, leaf)); +int unlockpt(int fd) __attribute__((nothrow, leaf)); +char *ptsname(int fd) __attribute__((nothrow, leaf)); +pid_t setsid(void) __attribute__((nothrow, leaf)); + +static const int TCIFLUSH = 0; +int tcdrain(int fd) __attribute__((nothrow, leaf)); +int tcflush(int fd, int queue_selector) __attribute__((nothrow, leaf)); +]] + +local CHUNK_SIZE = 80 * 40 -- max. nb of read bytes (reduce this, if taps are not detected) local Terminal = WidgetContainer:new{ name = "terminal", - command = "", - dump_file = util.realpath(DataStorage:getDataDir()) .. "/terminal_output.txt", - items_per_page = 16, - settings = LuaSettings:open(DataStorage:getSettingsDir() .. "/terminal_shortcuts.lua"), - shortcuts_dialog = nil, - shortcuts_menu = nil, - -- shortcuts_file = DataStorage:getSettingsDir() .. "/terminal_shortcuts.lua", - shortcuts = {}, - source = "terminal", + history = "", + is_shell_open = false, + buffer_size = 1024 * G_reader_settings:readSetting("terminal_buffer_size", 16), -- size in kB + refresh_time = 0.2, + terminal_data = ".", } -function Terminal:onDispatcherRegisterActions() - Dispatcher:registerAction("show_terminal", { category = "none", event = "TerminalStart", title = _("Show terminal"), general=true, }) -end - function Terminal:init() self:onDispatcherRegisterActions() self.ui.menu:registerToMainMenu(self) - self.items_per_page = G_reader_settings:readSetting("items_per_page") or 16 - self.shortcuts = self.settings:readSetting("shortcuts", {}) -end -function Terminal:saveShortcuts() - self.settings:flush() - UIManager:show(InfoMessage:new{ - text = _("Shortcuts saved"), - timeout = 2 - }) + self.chunk_size = CHUNK_SIZE + self.chunk = ffi.new('uint8_t[?]', self.chunk_size) + + self.terminal_data = DataStorage:getDataDir() + lfs.mkdir(self.terminal_data .. "/scripts") + os.remove("terminal.pid") -- clean leftover from last run end -function Terminal:manageShortcuts() - self.shortcuts_dialog = CenterContainer:new { - dimen = Screen:getSize(), - } - self.shortcuts_menu = Menu:new{ - show_parent = self.ui, - width = Screen:getWidth(), - height = Screen:getHeight(), - covers_fullscreen = true, -- hint for UIManager:_repaint() - is_borderless = true, - is_popout = false, - perpage = self.items_per_page, - onMenuHold = self.onMenuHoldShortcuts, - _manager = self, - } - table.insert(self.shortcuts_dialog, self.shortcuts_menu) - self.shortcuts_menu.close_callback = function() - UIManager:close(self.shortcuts_dialog) +function Terminal:spawnShell(cols, rows) + if self.is_shell_open then + self.input_widget:resize(rows, cols) + self.input_widget:interpretAnsiSeq(self:receive()) + return end - -- sort the shortcuts: - if #self.shortcuts > 0 then - table.sort(self.shortcuts, function(v1, v2) - return v1.text < v2.text - end) + local shell = G_reader_settings:readSetting("terminal_shell", "sh") + + local ptmx_name = "/dev/ptmx" + self.ptmx = C.open(ptmx_name, bit.bor(C.O_RDWR, C.O_NONBLOCK, C.O_CLOEXEC)) + + if C.grantpt(self.ptmx) ~= 0 then + logger.err("Terminal: can not grantpt") + end + if C.unlockpt(self.ptmx) ~= 0 then + logger.err("Terminal: can not unockpt") end - self:updateItemTable() -end -function Terminal:updateItemTable() - local item_table = {} - if #self.shortcuts > 0 then - local actions_count = 3 -- separator + actions - for nr, f in ipairs(self.shortcuts) do - local item = { - nr = nr, - text = f.text, - commands = f.commands, - editable = true, - deletable = true, - callback = function() - -- so we know which middle button to display in the results: - self.source = "shortcut" - -- execute immediately, skip terminal dialog: - self.command = self:ensureWhitelineAfterCommands(f.commands) - Trapper:wrap(function() - self:execute() - end) - end - } - table.insert(item_table, item) - -- add page actions at end of each page with shortcuts: - local factor = self.items_per_page - actions_count - if nr % factor == 0 or nr == #self.shortcuts then - -- insert "separator": - table.insert(item_table, { - text = " ", - deletable = false, - editable = false, - callback = function() - self:manageShortcuts() - end, - }) - -- actions: - self:insertPageActions(item_table) + self.slave_pty = ffi.string(C.ptsname(self.ptmx)) + + logger.info("Terminal: slave_pty", self.slave_pty) + + local pid = C.fork() + if pid < 0 then + logger.err("Terminal: fork failed") + return + elseif pid == 0 then + C.close(self.ptmx) + C.setsid() + + pid = C.getpid() + local pid_file = io.open("terminal.pid", "w") + if pid_file then + pid_file:write(pid) + pid_file:close() + end + + local pts = C.open(self.slave_pty, C.O_RDWR) + if pts == -1 then + logger.err("Terminal: cannot open slave pty: ", pts) + return + end + + C.dup2(pts, 0); + C.dup2(pts, 1); + C.dup2(pts, 2); + C.close(pts); + + if cols and rows then + if not Device:isAndroid() then + os.execute("stty cols " .. cols .. " rows " .. rows) end end - -- no shortcuts defined yet: - else - self:insertPageActions(item_table) - end - local title = N_("Terminal shortcut", "Terminal shortcuts", #self.shortcuts) - self.shortcuts_menu:switchItemTable(tostring(#self.shortcuts) .. " " .. title, item_table) - UIManager:show(self.shortcuts_dialog) -end -function Terminal:insertPageActions(item_table) - table.insert(item_table, { - text = " " .. _("to terminal…"), - deletable = false, - editable = false, - callback = function() - self:terminal() - end, - }) - table.insert(item_table, { - text = " " .. _("close…"), - deletable = false, - editable = false, - callback = function() - return false - end, - }) -end + C.setenv("TERM", "vt52", 1) + C.setenv("ENV", "./plugins/terminal.koplugin/profile", 1) + C.setenv("BASH_ENV", "./plugins/terminal.koplugin/profile", 1) + C.setenv("TERMINAL_DATA", self.terminal_data, 1) + if Device:isAndroid() then + C.setenv("ANDROID", "ANDROID", 1) + end + if C.execlp(shell, shell) ~= 0 then + -- the following two prints are shown in the terminal emulator. + print("Terminal: something has gone really wrong in spawning the shell\n\n:-(\n") + print("Maybe an incorrect shell: '" .. shell .. "'\n") + os.exit() + end + os.exit() + return + end -function Terminal:onMenuHoldShortcuts(item) - if item.deletable or item.editable then - local shortcut_shortcuts_dialog - shortcut_shortcuts_dialog = ButtonDialog:new{ - buttons = {{ - { - text = _("Edit name"), - enabled = item.editable, - callback = function() - UIManager:close(shortcut_shortcuts_dialog) - if self._manager.shortcuts_dialog ~= nil then - UIManager:close(self._manager.shortcuts_dialog) - self._manager.shortcuts_dialog = nil - end - self._manager:editName(item) - end - }, - { - text = _("Edit commands"), - enabled = item.editable, - callback = function() - UIManager:close(shortcut_shortcuts_dialog) - if self._manager.shortcuts_dialog ~= nil then - UIManager:close(self._manager.shortcuts_dialog) - self._manager.shortcuts_dialog = nil - end - self._manager:editCommands(item) - end - }, - }, - { - { - text = _("Copy"), - enabled = item.editable, - callback = function() - UIManager:close(shortcut_shortcuts_dialog) - if self._manager.shortcuts_dialog ~= nil then - UIManager:close(self._manager.shortcuts_dialog) - self._manager.shortcuts_dialog = nil - end - self._manager:copyCommands(item) - end - }, - { - text = _("Delete"), - enabled = item.deletable, - callback = function() - UIManager:close(shortcut_shortcuts_dialog) - if self._manager.shortcuts_dialog ~= nil then - UIManager:close(self._manager.shortcuts_dialog) - self._manager.shortcuts_dialog = nil - end - self._manager:deleteShortcut(item) - end - } - }} - } - UIManager:show(shortcut_shortcuts_dialog) - return true + self.is_shell_open = true + if Device:isAndroid() then + -- feed the following commands to the running shell + self:transmit("export TERM=vt52\n") + self:transmit("stty cols " .. cols .. " rows " .. rows .."\n") end -end -function Terminal:copyCommands(item) - local new_item = { - text = item.text .. " (copy)", - commands = item.commands - } - table.insert(self.shortcuts, new_item) - UIManager:show(InfoMessage:new{ - text = _("Shortcut copied"), - timeout = 2 - }) - self:saveShortcuts() - self:manageShortcuts() -end + self.input_widget:resize(rows, cols) + self.input_widget:interpretAnsiSeq(self:receive()) -function Terminal:editCommands(item) - local edit_dialog - edit_dialog = InputDialog:new{ - title = T(_('Edit commands for "%1"'), item.text), - input = item.commands, - para_direction_rtl = false, -- force LTR - input_type = "string", - allow_newline = true, - cursor_at_end = true, - fullscreen = true, - buttons = {{{ - text = _("Cancel"), - callback = function() - UIManager:close(edit_dialog) - edit_dialog = nil - self:manageShortcuts() - end, - }, { - text = _("Save"), - callback = function() - local input = edit_dialog:getInputText() - UIManager:close(edit_dialog) - edit_dialog = nil - if input:match("[A-Za-z]") then - self.shortcuts[item.nr]["commands"] = input - self:saveShortcuts() - self:manageShortcuts() - end - end, - }}}, - } - UIManager:show(edit_dialog) - edit_dialog:onShowKeyboard() + logger.info("Terminal: spawn done") end -function Terminal:editName(item) - local edit_dialog - edit_dialog = InputDialog:new{ - title = _("Edit name"), - input = item.text, - para_direction_rtl = false, -- force LTR - input_type = "string", - allow_newline = false, - cursor_at_end = true, - fullscreen = true, - buttons = {{{ - text = _("Cancel"), - callback = function() - UIManager:close(edit_dialog) - edit_dialog = nil - self:manageShortcuts() - end, - }, { - text = _("Save"), - callback = function() - local input = edit_dialog:getInputText() - UIManager:close(edit_dialog) - edit_dialog = nil - if input:match("[A-Za-z]") then - self.shortcuts[item.nr]["text"] = input - self:saveShortcuts() - self:manageShortcuts() - end - end, - }}}, - } - UIManager:show(edit_dialog) - edit_dialog:onShowKeyboard() +function Terminal:receive() + local last_result = "" + repeat + C.tcdrain(self.ptmx) + local count = tonumber(C.read(self.ptmx, self.chunk, self.chunk_size)) + if count > 0 then + last_result = last_result .. string.sub(ffi.string(self.chunk), 1, count) + end + until count <= 0 or #last_result >= self.chunk_size - 1 + return last_result end -function Terminal:deleteShortcut(item) - for i = #self.shortcuts, 1, -1 do - local element = self.shortcuts[i] - if element.text == item.text and element.commands == item.commands then - table.remove(self.shortcuts, i) - end +function Terminal:refresh(reset) + if reset then + self.refresh_time = 1/32 + UIManager:unschedule(Terminal.refresh) end - self:saveShortcuts() - self:manageShortcuts() -end -function Terminal:onTerminalStart() - -- if shortcut commands are defined, go directly to the the shortcuts manager (so we can execute scripts more quickly): - if #self.shortcuts == 0 then - self:terminal() + local next_text = self:receive() + if next_text ~= "" then + self.input_widget:interpretAnsiSeq(next_text) + self.input_widget:trimBuffer(self.buffer_size) + if self.is_shell_open then + UIManager:tickAfterNext(function() + UIManager:scheduleIn(self.refresh_time, Terminal.refresh, self) + end) + end else - self:manageShortcuts() + if self.is_shell_open then + if self.refresh_time > 5 then + self.refresh_time = self.refresh_time + elseif self.refresh_time > 1 then + self.refresh_time = self.refresh_time * 1.1 + else + self.refresh_time = self.refresh_time * 2 + end + UIManager:scheduleIn(self.refresh_time, Terminal.refresh, self) + end end end -function Terminal:terminal() - self.input = InputDialog:new{ - title = _("Enter a command and press \"Execute\""), - input = self.command:gsub("\n+$", ""), - para_direction_rtl = false, -- force LTR - input_type = "string", - allow_newline = true, - cursor_at_end = true, - fullscreen = true, - buttons = {{{ - text = _("Cancel"), - callback = function() - UIManager:close(self.input) - end, - }, { - text = _("Shortcuts"), - callback = function() - UIManager:close(self.input) - self:manageShortcuts() - end, - }, { - text = _("Save"), - callback = function() - local input = self.input:getInputText() - if input:match("[A-Za-z]") then - - local function callback(name) - local new_shortcut = { - text = name, - commands = input, - } - table.insert(self.shortcuts, new_shortcut) - self:saveShortcuts() - end - - local prompt - prompt = InputDialog:new{ - title = _("Name"), - input = "", - input_type = "text", - fullscreen = true, - condensed = true, - allow_newline = false, - cursor_at_end = true, - buttons = {{{ - text = _("Cancel"), - callback = function() - UIManager:close(prompt) - end, - }, - { - text = _("Save"), - is_enter_default = true, - callback = function() - local newval = prompt:getInputText() - UIManager:close(prompt) - callback(newval) - end, - }}} - } - UIManager:show(prompt) - prompt:onShowKeyboard() - end - end, - }, { - text = _("Execute"), - callback = function() - UIManager:close(self.input) - -- so we know which middle button to display in the results: - self.source = "terminal" - self.command = self:ensureWhitelineAfterCommands(self.input:getInputText()) - Trapper:wrap(function() - self:execute() - end) - end, - }}}, - } - UIManager:show(self.input) - self.input:onShowKeyboard() +function Terminal:transmit(chars) + C.write(self.ptmx, chars, #chars) + self:refresh(true) end --- for prettier formatting of output by separating commands and result thereof with a whiteline: -function Terminal:ensureWhitelineAfterCommands(commands) - if string.sub(commands, -1) ~= "\n" then - commands = commands .. "\n" +--- kills a running shell +-- @param ask if true ask if a shell is running, don't kill +-- @return pid if shell is running, -1 otherwise +function Terminal:killShell(ask) + UIManager:unschedule(Terminal.refresh) + local pid_file = io.open("terminal.pid", "r") + if not pid_file then + return -1 end - return commands -end -function Terminal:execute() - local wait_msg = InfoMessage:new{ - text = _("Executing…"), - } - UIManager:show(wait_msg) - local entries = { self.command } - local command = self.command .. " 2>&1 ; echo" -- ensure we get stderr and output something - local completed, result_str = Trapper:dismissablePopen(command, wait_msg) - if completed then - table.insert(entries, result_str) - self:dump(entries) - table.insert(entries, _("Output was also written to")) - table.insert(entries, self.dump_file) + local pid = tonumber(pid_file:read("*a")) + pid_file:close() + + if ask then + return pid else - table.insert(entries, _("Execution canceled.")) + local terminate = "\03\n\nexit\n" + self:transmit(terminate) + -- do other things before killing first + self.is_shell_open = false + self.history = "" + os.remove("terminal.pid") + C.close(self.ptmx) + + C.kill(pid, C.SIGTERM) + + local status = ffi.new('int[1]') + -- status = tonumber(status[0]) + -- If still running: ret = 0 , status = 0 + -- If exited: ret = pid , status = 0 or 9 if killed + -- If no more running: ret = -1 , status = 0 + C.waitpid(pid, status, 0) -- wait until shell is terminated + + return -1 end - UIManager:close(wait_msg) - local viewer - local buttons_table - local back_button = { - text = _("Back"), - callback = function() - UIManager:close(viewer) - if self.source == "terminal" then - self:terminal() - else - self:manageShortcuts() - end - end, - } - local close_button = { - text = _("Close"), - callback = function() - UIManager:close(viewer) - end, +end + +function Terminal:getCharSize() + local tmp = TextWidget:new{ + text = " ", + face = self.input_face, } - if self.source == "terminal" then - buttons_table = { + return tmp:getSize().w +end + +function Terminal:generateInputDialog() + return InputDialog:new{ + title = _("Terminal Emulator"), + input = self.history, + input_face = self.input_face, + para_direction_rtl = false, + input_type = "string", + allow_newline = false, + cursor_at_end = true, + fullscreen = true, + inputtext_class = TermInputText, + buttons = {{ + { + text = "↹", -- tabulator "⇤" and "⇥" + callback = function() + self:transmit("\009") + end, + }, + { + text = "/", -- slash + callback = function() + self:transmit("/") + end, + }, { - back_button, - { - text = _("Shortcuts"), - -- switch to shortcuts: - callback = function() - UIManager:close(viewer) - self:manageShortcuts() + text = _("Esc"), -- @translators This is the ESC-key on the keyboard. + callback = function() + self:transmit("\027") + end, + }, + { + text = _("Ctrl"), -- @translators This is the CTRL-key on the keyboard. + callback = function() + self.ctrl = true + end, + }, + { + text = _("Ctrl-C"), -- @translators This is the CTRL-C key combination. + callback = function() + self:transmit("\003") + -- consume and drop everything + C.tcflush(self.ptmx, C.TCIFLUSH) + while self:receive() ~= "" do + C.tcflush(self.ptmx, C.TCIFLUSH) + end + self.input_widget:addChars("\003", true) -- as we flush the queue + end, + }, + { + text = "⎚", --clear + callback = function() + self.history = "" + self.input = {} + self.input_dialog:setInputText("$ ") + end, + }, + { + text = "⇧", + callback = function() + self.input_widget:upLine() + end, + hold_callback = function() + self.input_widget:scrollUp() + end, + }, + { + text = "⇩", + callback = function() + self.input_widget:downLine() + end, + hold_callback = function() + self.input_widget:scrollDown() + end, + }, + { + text = "☰", -- settings menu + callback = function () + UIManager:close(self.input_widget.keyboard) + Aliases:show(self.terminal_data .. "/scripts/aliases", + function() + UIManager:show(self.input_widget.keyboard) + UIManager:setDirty(self.input_dialog, "fast") -- is there a better solution end, - }, - close_button, + self) + end, }, - } - else - buttons_table = { { - back_button, - { - text = _("Terminal"), - -- switch to terminal: - callback = function() - UIManager:close(viewer) - self:terminal() + text = "✕", --cancel + callback = function() + UIManager:show(MultiConfirmBox:new{ + text = _("You can close the terminal, but leave the shell open for further commands or quit it now."), + choice1_text = _("Close"), + choice1_callback = function() + self.history = self.input_dialog:getInputText() + -- trim trialing spaces and newlines + while self.history:sub(#self.history, #self.history) == "\n" + or self.history:sub(#self.history, #self.history) == " " do + self.history = self.history:sub(1, #self.history - 1) + end + + UIManager:close(self.input_dialog) + if self.touchmenu_instance then + self.touchmenu_instance:updateItems() + end + end, + choice2_text = _("Quit"), + choice2_callback = function() + self.history = "" + self:killShell() + UIManager:close(self.input_dialog) + if self.touchmenu_instance then + self.touchmenu_instance:updateItems() + end end, - }, - close_button, + }) + end, }, - } - end - viewer = TextViewer:new{ - title = _("Command output"), - text = table.concat(entries, "\n"), - justified = false, - text_face = Font:getFace("smallinfont"), - buttons_table = buttons_table, + }}, + enter_callback = function() + self:transmit("\r") + end, + strike_callback = function(chars) + if self.ctrl and #chars == 1 then + chars = string.char(chars:upper():byte() - ("A"):byte()+1) + self.ctrl = false + end + if chars == "\n" then + chars = "\r\n" + end + self:transmit(chars) + end, } - UIManager:show(viewer) end -function Terminal:dump(entries) - local content = table.concat(entries, "\n") - local file = io.open(self.dump_file, "w") - if file then - file:write(content) - file:close() - else - logger.warn("Failed to dump terminal output " .. content .. " to " .. self.dump_file) - end +function Terminal:onClose() + self:killShell() +end + +function Terminal:onTerminalStart(touchmenu_instance) + self.touchmenu_instance = touchmenu_instance + + self.input_face = Font:getFace("smallinfont", + G_reader_settings:readSetting("terminal_font_size", 14)) + self.ctrl = false + self.input_dialog = self:generateInputDialog() + self.input_widget = self.input_dialog._input_widget + + local scroll_bar_width = ScrollTextWidget.scroll_bar_width + + ScrollTextWidget.text_scroll_span + self.maxc = math.floor((self.input_widget.width - scroll_bar_width) / self:getCharSize()) + + self.maxr = math.floor(self.input_widget.height + / self.input_widget:getLineHeight()) + + self.store_position = 1 + + logger.dbg("Terminal: resolution= " .. self.maxc .. "x" .. self.maxr) + + self:spawnShell(self.maxc, self.maxr) + UIManager:show(self.input_dialog) + UIManager:scheduleIn(0.25, Terminal.refresh, self, true) + self.input_dialog:onShowKeyboard(true) end function Terminal:addToMainMenu(menu_items) menu_items.terminal = { text = _("Terminal emulator"), +-- sorting_hint = "more_tools", keep_menu_open = true, - callback = function() - self:onTerminalStart() - end, + sub_item_table = { + { + text = _("About terminal emulator"), + callback = function() + local about_text = _([[Terminal emulator can start a shell (command prompt). + +There are two environment variables TERMINAL_HOME and TERMINAL_DATA containing the path of the install and the data folders. + +Commands to be executed on start can be placed in: +'$TERMINAL_DATA/scripts/profile.user'. + +Aliases (shortcuts) to frequently used commands can be placed in: +'$TERMINAL_DATA/scripts/aliases'.]]) + if not Device:isAndroid() then + about_text = about_text .. _("\n\nYou can use 'shfm' as a file manager, '?' shows shfm’s help message.") + end + + UIManager:show(InfoMessage:new{ + text = about_text, + }) + end, + keep_menu_open = true, + separator = true, + }, + { + text_func = function() + local state = self.is_shell_open and "running" or "not running" + return T(_("Open terminal session (%1)"), state) + end, + callback = function(touchmenu_instance) + self:onTerminalStart(touchmenu_instance) + end, + keep_menu_open = true, + }, + { + text = _("End terminal session"), + enabled_func = function() + return self:killShell(true) >= 0 + end, + callback = function(touchmenu_instance) + self:killShell() + if touchmenu_instance then touchmenu_instance:updateItems() end + end, + keep_menu_open = true, + separator = true, + }, + { + text_func = function() + return T(_("Font size: %1"), + G_reader_settings:readSetting("terminal_font_size", 14)) + end, + callback = function(touchmenu_instance) + local cur_size = G_reader_settings:readSetting("terminal_font_size") + local size_spin = SpinWidget:new{ + value = cur_size, + value_min = 10, + value_max = 30, + value_hold_step = 2, + default_value = 14, + title_text = _("Terminal emulator font size "), + callback = function(spin) + G_reader_settings:saveSetting("terminal_font_size", spin.value) + if touchmenu_instance then touchmenu_instance:updateItems() end + end, + } + UIManager:show(size_spin) + end, + keep_menu_open = true, + }, + { + text_func = function() + return T(_("Buffer size: %1 kB"), + G_reader_settings:readSetting("terminal_buffer_size", 16)) + end, + callback = function(touchmenu_instance) + local cur_buffer = G_reader_settings:readSetting("terminal_buffer_size") + local buffer_spin = SpinWidget:new{ + value = cur_buffer, + value_min = 10, + value_max = 30, + value_hold_step = 2, + default_value = 16, + title_text = _("Terminal emulator buffer size (kB)"), + callback = function(spin) + G_reader_settings:saveSetting("terminal_buffer_size", spin.value) + if touchmenu_instance then touchmenu_instance:updateItems() end + end, + } + UIManager:show(buffer_spin) + end, + keep_menu_open = true, + }, + { + text_func = function() + return T(_("Shell executable: %1"), + G_reader_settings:readSetting("terminal_shell", "sh")) + end, + callback = function(touchmenu_instance) + self.shell_dialog = InputDialog:new{ + title = _("Shell to use"), + description = _("Here you can select the startup shell.\nDefault: sh"), + input = G_reader_settings:readSetting("terminal_shell", "sh"), + buttons = {{ + { + text = _("Cancel"), + callback = function() + UIManager:close(self.shell_dialog) + end, + }, + { + text = _("Default"), + callback = function() + G_reader_settings:saveSetting("terminal_shell", "sh") + UIManager:close(self.shell_dialog) + if touchmenu_instance then + touchmenu_instance:updateItems() + end + end, + }, + { + text = _("Save"), + is_enter_default = true, + callback = function() + local new_shell = self.shell_dialog:getInputText() + if new_shell == "" then + new_shell = "sh" + end + G_reader_settings:saveSetting("terminal_shell", new_shell) + UIManager:close(self.shell_dialog) + if touchmenu_instance then + touchmenu_instance:updateItems() + end + end + }, + }}} + UIManager:show(self.shell_dialog) + self.shell_dialog:onShowKeyboard() + end, + keep_menu_open = true, + }, + } } end +function Terminal:onDispatcherRegisterActions() + Dispatcher:registerAction("terminal", + {category = "none", event = "TerminalStart", title = _("Terminal Emulator"), device = true}) +end + return Terminal diff --git a/plugins/terminal.koplugin/profile b/plugins/terminal.koplugin/profile new file mode 100644 index 000000000..aa028aa57 --- /dev/null +++ b/plugins/terminal.koplugin/profile @@ -0,0 +1,53 @@ +#!/bin/sh + +# This file gets executed on every start of the terminal shell. +# Do not edit this file! +# You may change the contents of 'aliases' and 'profile.user' +# + +# shellcheck source=/dev/null + +TERMINAL_HOME="$(pwd)" +# make TERMINAL_DATA an absolute path +cd "${TERMINAL_DATA}" || exit +TERMINAL_DATA="$(pwd)" +HOME="${TERMINAL_DATA}" + +PATH="${PATH}:${TERMINAL_DATA}/scripts:${TERMINAL_HOME}/plugins/terminal.koplugin/" +TERM="vt52" + +PS1="$ " +if [ "$(id -u)" -eq "0" ]; then + PS1="# " +fi + +EDITOR="nano" + +SHFM_OPENER="${TERMINAL_HOME}/plugins/terminal.koplugin/shfm_opener.sh" + +export EDITOR +export HOME +export TERMINAL_HOME +export TERMINAL_DATA +export PATH +export PS1 +export SHFM_OPENER +export TERM + +# Source a user profile if it exists. +USER_PROFILE="${TERMINAL_DATA}/scripts/profile.user" +if [ -f "${USER_PROFILE}" ]; then + . "${USER_PROFILE}" +fi + +# Source command aliases if they exist. +# These will be managed by terminal.koplugin so you don't need +# to change it manually. +ALIASES="${TERMINAL_DATA}/scripts/aliases" +if [ -f "${ALIASES}" ]; then + . "${ALIASES}" +fi + +if [ -z "${ANDROID}" ]; then + echo "You can use shfm as a filemanager, ? shows help in shfm." +fi diff --git a/plugins/terminal.koplugin/shfm b/plugins/terminal.koplugin/shfm new file mode 100755 index 000000000..b119942ae --- /dev/null +++ b/plugins/terminal.koplugin/shfm @@ -0,0 +1,1186 @@ +#!/bin/sh + +#-------------------------------------------------------------------------------- +### For source code see: https://github.com/Sketch98/shfm + +# The MIT License (MIT) +# +# Copyright (c) 2020 Dylan Araps +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +#-------------------------------------------------------------------------------- + +# these are here to help shellcheck +# they will be overwritten in main immediately +y=1 +tab_num=1 + +esc() { + case $1 in + # vt100 (IL is vt102) (DECTCEM is vt520) + CUD) printf '%s[%sB' "$esc_c" "$2" ;; # cursor down + CUP) printf '%s[%s;%sH' "$esc_c" "$2" "$3" ;; # cursor home + CUU) printf '%s[%sA' "$esc_c" "$2" ;; # cursor up + DECAWM) printf '%s[?7%s' "$esc_c" "$2" ;; # line wrap + DECRC) printf '%s8' "$esc_c" ;; # cursor restore + DECSC) printf '%s7' "$esc_c" ;; # cursor save + DECSTBM) printf '%s[%s;%sr' "$esc_c" "$2" "$3" ;; # scroll region + DECTCEM) printf '%s[?25%s' "$esc_c" "$2" ;; # cursor visible + ED[0-2]) printf '%s[%sJ' "$esc_c" "${1#ED}" ;; # clear screen + EL[0-2]) printf '%s[%sK' "$esc_c" "${1#EL}" ;; # clear line + IL) printf '%s[%sL' "$esc_c" "$2" ;; # insert line + SGR) printf '%s[%s;%sm' "$esc_c" "$2" "$3" ;; # colors + + # xterm (since 1988, supported widely) + screen_alt) printf '%s[?1049%s' "$esc_c" "$2" ;; # alternate buffer + esac +} + +term_setup() { + stty -icanon -echo + esc screen_alt h + esc DECAWM l + esc DECTCEM l + esc ED2 + + set -f + # false-positive, behavior intentional, globbing is disabled. + # shellcheck disable=2046 + set +f -- $(IFS=' ' stty size) + + LINES=$1 COLUMNS=$2 + + # space for status_line + bottom=$((LINES - 3)) + esc DECSTBM 1 $bottom +} + +term_resize() { + set -f + # false-positive, behavior intentional, globbing is disabled. + # shellcheck disable=2046 + set +f -- $(IFS=' ' stty size) + + LINES=$1 COLUMNS=$2 + + # space for status_line + bottom=$((LINES - 3)) + + # tell main loop to redraw because we don't have files list $@ here + set_type=resize + resized=1 +} + +term_reset() { + esc DECAWM h + esc DECTCEM h + esc ED2 + esc DECSTBM + stty "$stty" +} + +term_scroll_down() { + [ $y -ge $# ] && return + + y=$((y + 1)) + y2=$((y2 + 1 < bottom ? y2 + 1 : bottom)) + + line_print $((y - 1)) "$@" + printf '\n' + line_print $y "$@" + status_line $# +} + +term_scroll_up() { + [ $y -le 1 ] && return + + y=$((y - 1)) + + line_print $((y + 1)) "$@" + + case $y2 in + 1) esc IL ;; + *) esc CUU; y2=$((y2 > 1 ? y2 - 1 : 1)) + esac + + line_print $y "$@" + status_line $# +} + +cmd_run() { + term_reset + printf %s "$@" + "$@" 2>&1 || : + term_setup + hist=2 +} + +file_escape() { + tmp=$1 safe= + + # loop over string char by char + while c=${tmp%"${tmp#?}"}; do + case $c in + '') return ;; + [[:cntrl:]]) safe=$safe\? ;; + *) safe=$safe$c + esac + + tmp=${tmp#?} + done +} + +hist_search() { + hist=0 j=1 + y=1 y2=1 # default values in case nothing found + + for file do + case ${PWD%%/}/$file in + "$old_pwd") + y=$j + min=$((bottom + y - $#)) + mid=$((mid < min ? min : mid)) + y2=$((j >= bottom ? mid : j)) + cur=$file + esac + j=$((j + 1)) + done +} + +list_print() { + esc ED2 + esc CUP + + i=1 + end=$((bottom + 1)) + mid=$((bottom / 4 < 5 ? 1 : bottom / 4)) + + case $# in + 0) set -- empty ;; + 1) [ -e "$1" ] || [ "$1" = 'no results' ] || set -- empty + esac + + case $hist in + 2) # redraw after cmd run + shift $((y - y2)) + ;; + + 1) # redraw after go-to-parent + hist_search "$@" + shift $((y >= bottom ? y - mid : 0)) + ;; + + *) # everything else + shift $((y >= bottom ? y - bottom : 0)) + ;; + esac + hist=0 + + for file do + [ $i -eq $y2 ] && esc SGR 0 7 + if [ $i -lt $end ]; then + line_format "$file" + esc CUD + fi + i=$((i + 1)) + done + + esc CUP $y2 +} + +redraw() { + list_print "$@" + status_line $# +} + +status_line() { + esc DECSC + esc CUP $((LINES - 1)) + + ## lazy debug + # esc SGR 32 32 + # printf %s "$y $y2 $bottom ${#others} ${selected:-empty}" + # esc EL0 + # esc CUD + # esc SGR 33 33 + # printf '\r%s' "${others:-empty};$LINES;$COLUMNS;$count" + # esc EL0 + # esc CUD + # esc SGR 0 0 + + printf '\r[ ' + [ -n "$tab1" ] && esc SGR 36 36 && [ $tab_num -eq 1 ] && esc SGR 1 7 + printf 1; esc SGR 0 0; printf ' ' + [ -n "$tab2" ] && esc SGR 36 36 && [ $tab_num -eq 2 ] && esc SGR 1 7 + printf 2; esc SGR 0 0; printf ' ' + [ -n "$tab3" ] && esc SGR 36 36 && [ $tab_num -eq 3 ] && esc SGR 1 7 + printf 3; esc SGR 0 0; printf ' ' + [ -n "$tab4" ] && esc SGR 36 36 && [ $tab_num -eq 4 ] && esc SGR 1 7 + printf 4; esc SGR 0 0; printf ' ] ' + esc SGR 36 36 + esc SGR 1 7 + printf ' %s ' $((num_sel + num_others)) + esc SGR 0 0 + esc CUD + + case $USER in + root) esc SGR 31 7 ;; + *) esc SGR 36 7 ;; + esac + + printf '\r%*s\r%s ' "$COLUMNS" "" "($y/$1)" + + case $ltype in + '') printf %s "$PWD" ;; + *) printf %s "$ltype" ;; + esac + + esc SGR 0 0 + esc DECRC +} + +prompt() { + esc DECSC + esc CUP "$LINES" + printf %s "$1" + esc DECTCEM h + esc EL0 + + stty icanon echo + read -r ans ||: + stty -icanon -echo + + esc DECRC + esc DECTCEM l + status_line "$2" + [ -n "$ans" ] +} + +yes_no() { + esc DECSC + esc CUP "$LINES" + printf %s "$1" + esc DECTCEM h + esc EL0 + + key=$(dd ibs=1 count=1 2>/dev/null) + + esc DECRC + esc DECTCEM l + status_line "$2" + [ "$key" = y ] +} + +line_print() { + offset=$1 + [ "$offset" -eq $y ] && esc SGR 0 7 + shift "$offset" + [ "$offset" -eq $y ] && cur=$1 + line_format "$1" +} + +line_format() { + check_selected "$1" && printf + || printf ' ' + file_escape "$1" + [ -d "$1" ] && esc SGR 1 31 + printf %s "$safe" + [ -d "$1" ] && printf / + esc SGR 0 0 + esc EL0 + printf '\r' +} + +to_tab() { + old_info=$PWD$hidden$ltype$dirs_first + old_pos=$y$y2 + case $tab_num in + 1) + tab1=$PWD + hidden1=$hidden + ltype1=$ltype + dirs_first1=$dirs_first + y_1=$y + y2_1=$y2 + ;; + + 2) + tab2=$PWD + hidden2=$hidden + ltype2=$ltype + dirs_first2=$dirs_first + y_2=$y + y2_2=$y2 + ;; + + 3) + tab3=$PWD + hidden3=$hidden + ltype3=$ltype + dirs_first3=$dirs_first + y_3=$y + y2_3=$y2 + ;; + + 4) + tab4=$PWD + hidden4=$hidden + ltype4=$ltype + dirs_first4=$dirs_first + y_4=$y + y2_4=$y2 + ;; + esac + case $1 in + 1) + tab_num=1 + if [ -z "$tab1" ]; then + tab1=$PWD + hidden1=$hidden + ltype1=$ltype + dirs_first1=$dirs_first + y_1=1 + y2_1=1 + fi + nwd=$tab1 + ;; + + 2) + tab_num=2 + if [ -z "$tab2" ]; then + tab2=$PWD + hidden2=$hidden + ltype2=$ltype + dirs_first2=$dirs_first + y_2=1 + y2_2=1 + fi + nwd=$tab2 + ;; + + 3) + tab_num=3 + if [ -z "$tab3" ]; then + tab3=$PWD + hidden3=$hidden + ltype3=$ltype + dirs_first3=$dirs_first + y_3=1 + y2_3=1 + fi + nwd=$tab3 + ;; + + 4) + tab_num=4 + if [ -z "$tab4" ]; then + tab4=$PWD + hidden4=$hidden + ltype4=$ltype + dirs_first4=$dirs_first + y_4=1 + y2_4=1 + fi + nwd=$tab4 + ;; + esac + case $tab_num in + 1) + hidden=$hidden1 + ltype=$ltype1 + dirs_first=$dirs_first1 + y=$y_1 + y2=$y2_1 + ;; + + 2) + hidden=$hidden2 + ltype=$ltype2 + dirs_first=$dirs_first2 + y=$y_2 + y2=$y2_2 + ;; + + 3) + hidden=$hidden3 + ltype=$ltype3 + dirs_first=$dirs_first3 + y=$y_3 + y2=$y2_3 + ;; + + 4) + hidden=$hidden4 + ltype=$ltype4 + dirs_first=$dirs_first4 + y=$y_4 + y2=$y2_4 + ;; + esac + switched_tabs=true changed=all + [ "$nwd$hidden$ltype$dirs_first" = "$old_info" ] && changed=pos && [ "$y$y2" = "$old_pos" ] && changed= + : +} + +try_tabs() { + unset nwd + for tab do + case $tab in + 1) [ -z "$tab1" ] && continue ;; + 2) [ -z "$tab2" ] && continue ;; + 3) [ -z "$tab3" ] && continue ;; + 4) [ -z "$tab4" ] && continue ;; + esac + to_tab "$tab" && return 0 + done + return 1 +} + +next_tab() { + ct=$tab_num + nums= + while true; do + ct=$((ct == num_tabs ? 1 : ct + 1)) + [ $ct -eq $tab_num ] && break + [ -n "$nums" ] && nums="$nums $ct" || nums=$ct + done + try_tabs $nums +} + +prev_tab() { + ct=$tab_num + nums= + while true; do + ct=$((ct == 1 ? num_tabs : ct - 1)) + [ $ct -eq $tab_num ] && break + [ -n "$nums" ] && nums="$nums $ct" || nums=$ct + done + try_tabs $nums +} + +check_selected() { + # return code denotes whether input is selected: 0 yes, 1 no + IFS=/ + set -f + for s in $selected; do + [ "$s" = "$1" ] && unset IFS && return 0 + done + set +f + unset IFS + return 1 +} + +add_selected() { + [ -e "$1" ] || return + IFS=/ + set -f + for s in $selected; do + [ "$s" = "$1" ] && unset IFS && return + done + set +f + unset IFS + selected=$selected$1/ + num_sel=$((num_sel + 1)) +} + +remove_selected() { + # return code denotes whether input was removed: 0 yes, 1 no + [ -z "$selected" ] && return 1 + selected=/$selected + beg=${selected%%/"$1"/*} + end=${selected##*/"$1"/} + if [ -z "$beg$end" ]; then + selected= + elif [ "$beg" = "$end" ]; then + selected=${selected#/} + return 1 + else + selected=$beg/$end + selected=${selected#/} + selected=${selected%/}/ + fi + num_sel=$((num_sel - 1)) +} + +filter_inpwd() { + IFS=/ + unset cwd fn inpwd notinpwd + num_others=$num_sel + num_sel=0 + set -f -- "$1//" + for piece in $1; do + if [ -z "$piece" ]; then + [ -z "$fn" ] && continue + if [ "$PWD" = "$cwd" ]; then + inpwd=$inpwd$fn/ + num_sel=$((num_sel + 1)) + else + notinpwd=$notinpwd$cwd/$fn/ + num_others=$((num_others + 1)) + fi + unset cwd fn + elif [ -n "$fn" ]; then + cwd=$cwd/$fn + fi + fn=$piece + done + set +f + unset IFS +} + +add_paths() { + IFS=/ + with_paths= + set -f + for s in $selected; do + [ -n "$s" ] && with_paths=$with_paths$PWD/$s/ + done + set +f + unset IFS selected +} + +switch_dir() { + [ "$1" = '..' ] && [ "$PWD" = / ] || [ "$1" = "$PWD" ] && return 1 + add_paths + cd -- "$1" >/dev/null 2>&1 || return 1 + filter_inpwd "$others" + selected=$inpwd + others=$notinpwd$with_paths +} + +others_op() { + case $1 in + p) msg_line 'Copying Files' ;; + v) msg_line 'Moving Files' ;; + esac + IFS=/ + set -f + unset cwd fn + others=$others// + for piece in $others; do + if [ -z "$piece" ]; then + if [ -n "$fn" ] && [ -e "$cwd/$fn" ]; then + case $1 in + p) + if [ -d "$cwd/$fn" ]; then + cp -r -- "$cwd/$fn" "$2" || : + else + cp -- "$cwd/$fn" "$2" || : + fi + ;; + + v) mv -- "$cwd/$fn" "$2" || : + esac + fi + unset cwd fn + elif [ -n "$fn" ]; then + cwd=$cwd/$fn + fi + fn=$piece + done + set +f + unset IFS others + num_others=0 +} + +get_cur() { + shift "$1" + cur=$1 +} + +redraw_cur_select(){ + esc SGR 0 7 + printf %s "$1" + esc SGR 0 0 + printf '\r' +} + +msg_line() { + esc DECSC + esc CUP "$LINES" + + case $USER in + root) esc SGR 31 7 ;; + *) esc SGR 36 7 ;; + esac + + printf '\r%*s\r%s ' "$COLUMNS" "" "$1" + + esc SGR 0 0 + esc DECRC +} + +populate_dirs_and_files () { + unset dirs files + IFS= + # globbing is intentional, word splitting is disabled. + # shellcheck disable=2231 + for item in $1 $2; do + [ -d "$item" ] && dirs=$dirs$item/ || [ ! -e "$item" ] || files=$files$item/ + done + unset IFS +} + +main() { + set -e + + case $1 in + -h|--help) + printf 'shfm -[hv] \n' + exit 0 + ;; + + -v|--version) + printf 'shfm 0.4.2\n' + exit 0 + ;; + + *) cd -- "${1:-"$PWD"}" + esac + + trash="${XDG_DATA_HOME:=$HOME/.local/share}/shfm/trash" + cache="${XDG_CACHE_HOME:=$HOME/.cache}/shfm" + [ -e "$trash" ] || mkdir -p "$trash" + [ -e "$cache" ] || mkdir -p "$cache" + rename_file="$cache/bulk_rename$$" + exit_file="$cache/exit" + + esc_c=$(printf '\033') + bs_char=$(printf '\177') + tab_char=$(printf '\011') + + stty=$(stty -g) + term_setup + trap 'term_reset; esc screen_alt l; printf "%s\n" "$PWD" > "$exit_file"' EXIT INT + trap 'term_resize' WINCH + + tab1=$PWD + num_tabs=4 + tab_num=1 + set_type=normal + hidden=0 + dirs_first=1 + num_sel=0 + num_others=0 + state=0 + resized=0 + y=1 y2=1 + + while true; do + case $set_type in + keybinds) + set -- ' j - down' \ + ' k - up' \ + ' l - open file or directory' \ + ' h - go up level' \ + ' g - go to top' \ + ' G - go to bottom' \ + ' d - toggle printing directories first' \ + ' q - quit' \ + ' : - cd to ' \ + ' / - search current directory *' \ + ' - - go to last directory' \ + ' ~ - go home' \ + ' ! - spawn shell' \ + ' . - toggle hidden files' \ + ' ? - show keybinds' \ + ' tab - next tab (or prev tab if shift is held)' \ + ' 1-4 - move to tab 1-4' \ + 'space - select (or deselect) current item' \ + ' p - copy selected items to current folder' \ + ' v - move selected items to current folder' \ + ' x - trash selected items (permanently delete if in trash)' \ + ' t - go to trash' \ + ' r - bulk rename' \ + ' a - select all' \ + ' A - invert selection' \ + ' n - create new file or directory' + ltype=keybinds + ;; + + search) + if [ $dirs_first -eq 0 ]; then + IFS= + # false positive, behavior intentional + # shellcheck disable=2086 + set -- $ans* + unset IFS + else + populate_dirs_and_files "$ans*" + IFS=/ + set -f + # word splitting intentional, globbing is disabled. + # shellcheck disable=2086 + set +f -- $dirs $files + unset IFS + fi + case $1$# in + "$ans*1") set -- 'no results' + esac + ltype="search $PWD/$ans*" + ;; + + normal) + if [ $dirs_first -eq 0 ]; then + if [ $hidden -eq 0 ]; then + set -- * + else + set -- .* * + fi + else + if [ $hidden -eq 0 ]; then + populate_dirs_and_files '*' + else + populate_dirs_and_files '.*' '*' + fi + IFS=/ + set -f + # word splitting intentional, globbing is disabled. + # shellcheck disable=2086 + set +f -- $dirs $files + unset IFS + fi + ;; + esac + if [ -n "$set_type" ]; then + [ "$1" = '.' ] && shift + [ "$1" = '..' ] && shift + if [ $# -eq 0 ]; then + y=1 y2=1 cur= + else + if [ $y -gt $# ]; then + y=$# + [ $y2 -gt $y ] && y2=$y + hist=2 + fi + get_cur $y "$@" + fi + # adjust y2 and scrollable area if window resized + if [ "$resized" -ne 0 ]; then + [ $y2 -gt $bottom ] && y2=$bottom + [ $y2 -gt $y ] && y2=$y + esc DECSTBM 1 $bottom + hist=2 + fi + redraw "$@" + set_type= + fi + key=$(dd ibs=1 count=1 2>/dev/null) + case $key$state in + 32) state=3 ;; + 42) state=4 ;; + 52) state=5 ;; + 62) state=6 ;; + + k?|A2) state=0 term_scroll_up "$@" ;; + + j?|B2) state=0 term_scroll_down "$@" ;; + + l?|C2|"$state") # ARROW RIGHT + state=0 + [ "$ltype" = keybinds ] && continue + if [ -d "$cur" ] && switch_dir "$cur"; then + set_type=normal y=1 y2=1 ltype= + elif [ -e "$cur" ]; then + cmd_run "${SHFM_OPENER:="${EDITOR:=vi}"}" "$cur" + redraw "$@" + fi + ;; + + h?|D2|"$bs_char"?) # ARROW LEFT + state=0 + old_pwd=$PWD + + case $ltype in + '') switch_dir .. || continue ;; + *) ltype= + esac + set_type=normal y=1 y2=1 hist=1 + ;; + + g?|H2) # HOME + state=0 + [ $y -eq 1 ] && continue + y=1 y2=1 cur=$1 + redraw "$@" + ;; + + G?|\~4) # END + state=0 + [ $# -eq 0 ] && continue + y=$# + y2=$(($# < bottom ? $# : bottom)) + get_cur "$y" "$@" + redraw "$@" + ;; + + \~5) # PGUP + state=0 + [ $y -eq 1 ] || [ $# -eq 0 ] && continue + y=$((y - bottom)) + [ $y -lt 1 ] && y=1 + [ $y -lt $y2 ] && y2=$y + hist=2 + get_cur $y "$@" + redraw "$@" + ;; + + \~6) # PGDOWN + state=0 + [ $y -eq $# ] || [ $# -eq 0 ] && continue + y=$((y + bottom)) + if [ $y -gt $# ]; then + y=$# + y2=$((bottom > $# ? $# : bottom)) + else + min=$((bottom + y - $#)) + y2=$((y2 < min ? min : y2)) + fi + hist=2 + get_cur $y "$@" + redraw "$@" + ;; + + ' '?) + [ "$ltype" = keybinds ] || [ ! -e "$cur" ] && continue + new_select='+' + if remove_selected "$cur"; then + new_select=' ' + else + selected=$selected$cur/ + num_sel=$((num_sel + 1)) + fi + if [ $y = $# ]; then + redraw_cur_select "$new_select" + status_line $# + else + term_scroll_down "$@" + fi + ;; + + "$tab_char"?) next_tab || to_tab $((tab_num == num_tabs ? 1 : tab_num + 1)) ;; + + Z2) + state=0 + prev_tab || to_tab $((tab_num == 1 ? num_tabs : tab_num - 1)) + ;; + + [1-9]?) [ "$key" -le $num_tabs ] && [ "$key" -ne $tab_num ] && to_tab "$key" ;; + + p?|v?) + # false positive, behavior intentional + # shellcheck disable=2015 + [ -z "$ltype" ] && [ -n "$others" ] || continue + others_op "$key" "$PWD" + set_type=normal + ;; + + x?|\~3) + state=0 + [ "$ltype" = keybinds ] || [ $# -eq 0 ] && continue + + del= + [ -z "$ltype$others" ] && [ "$PWD" = "$trash" ] && del=true + if [ -z "$selected$others" ]; then + key=c + elif check_selected "$cur"; then + key=s + else + [ -z $del ] && msg="trash 's'elected 'c'ur" || msg="permanently delete 's'elected 'c'ur" + yes_no "$msg" $# || : + fi + + case $key in + s) + [ -z $del ] && msg="send selected to trash? y/n" || msg="permanently delete selected? y/n" + yes_no "$msg" $# || continue + if [ -n "$selected" ]; then + y=1 y2=1 IFS=/ + set -f + # globbing disabled and word splitting intentional + # shellcheck disable=2086 + if [ -z $del ]; then + msg_line 'Trashing Files' + mv -- $selected "$trash" || : + else + msg_line 'Deleting Files' + rm -rf -- $selected + fi + set +f + unset IFS selected + num_sel=0 + fi + [ -n "$others" ] && others_op v "$trash" + ;; + + c) + [ -z $del ] && msg="send $cur to trash? y/n" || msg="permanently delete $cur? y/n" + # false positive, behavior intentional + # shellcheck disable=2015 + [ -e "$cur" ] && yes_no "$msg" $# || continue + remove_selected "$cur" || : + if [ -z $del ]; then + msg_line 'Trashing File' + mv -- "$cur" "$trash" || : + else + msg_line 'Deleting File' + rm -rf -- "$cur" + fi + [ $y -eq $# ] && y=$((y - 1)) + [ $y2 -eq $# ] && y2=$((y2 - 1)) + [ $y -eq 0 ] && y=1 + [ $y2 -eq 0 ] && y2=1 + ;; + + *) continue + esac + [ -z "$ltype" ] && set_type=normal || set_type=search + ;; + + d?) + [ "$ltype" = keybinds ] && continue + [ $dirs_first -eq 0 ] && dirs_first=1 || dirs_first=0 + [ -z "$ltype" ] && set_type=normal || set_type=search + y=1 y2=1 + ;; + + t?) + switch_dir "$trash" || continue + set_type=normal y=1 y2=1 ltype= + ;; + + r?) + [ -n "$ltype" ] || [ $# -lt 1 ] && continue + for w; do + printf '%s\n' "$w" + done > "$rename_file" + cmd_run "${EDITOR:=vi}" "$rename_file" + i=0 + while IFS= read -r r; do + i=$((i+1)) + done < "$rename_file" + [ "$i" -eq $# ] || continue + renames= + while IFS= read -r r; do + [ -n "$r" ] && [ ! -e "$r" ] && [ "$r" = "${r#*/}" ] && renames="$renames$1/$r/" + shift + done < "$rename_file" + IFS=/ + old= + for new in $renames; do + [ -z "$old" ] && old="$new" && continue + if [ ! -e "$new" ]; then + # Don't need to call msg_line because there's no chance + # of moving across disk boundary + mv -- "$old" "$new" + remove_selected "$old" && selected=$selected$new/ + fi + old= + done + rm "$rename_file" + unset IFS + set_type=normal + ;; + + a?) + [ $# -eq 0 ] || [ $num_sel -eq $# ] && continue + IFS=/ + selected=$*/ + num_sel=$# + unset IFS + + esc CUP + i=1 + last_item=$(($# + y2 - y > bottom ? bottom : $# + y2 - y)) + shift $((y - y2)) + esc SGR 0 0 + while [ $i -le $last_item ]; do + [ $i -eq $y2 ] && esc SGR 0 7 + printf + + [ $i -eq $y2 ] && esc SGR 0 0 + printf '\r' + esc CUD + i=$((i + 1)) + done + esc CUP $y2 + status_line $# + ;; + + A?) + [ $# -eq 0 ] && continue + + IFS=/ + new_selected= + esc CUP + i=1 + first_item=$((y - y2 + 1)) + last_item=$(($# + y2 - y > bottom ? bottom : $# + y2 - y)) + for file; do + was_selected= + for s in $selected; do + if [ "$s" = "$file" ]; then + was_selected=true + break + fi + done + [ -z "$was_selected" ] && new_selected="$new_selected$file/" + if [ $i -ge $first_item ] && [ $i -le $last_item ]; then + [ $i -eq $y ] && esc SGR 0 7 + [ -z "$was_selected" ] && printf + || printf ' ' + [ $i -eq $y ] && esc SGR 0 0 + printf '\r' + esc CUD + fi + i=$((i + 1)) + done + esc CUP $y2 + unset IFS + selected=$new_selected + num_sel=$(($# - num_sel)) + status_line $# + ;; + + n?) + [ -z "$ltype" ] || continue + yes_no "create new 'd'ir 'f'ile" $# || : + case $key in + d) + # false positive, behavior intentional + # shellcheck disable=2015 + prompt 'directory name: ' $# && [ ! -e "$ans" ] && [ "$ans" = "${ans#*/}" ] || continue + mkdir "$ans" + ;; + + f) + # false positive, behavior intentional + # shellcheck disable=2015 + prompt 'file name: ' $# && [ ! -e "$ans" ] && [ "$ans" = "${ans#*/}" ] || continue + touch "$ans" + ;; + + *) continue + esac + set_type=normal + ;; + + .?) + [ -n "$ltype" ] && continue + [ $hidden -eq 0 ] && hidden=1 || hidden=0 + set_type=normal y=1 y2=1 + ;; + + :?) + prompt "cd: " $# || continue + + # false positive, behavior intentional + # shellcheck disable=2088 + case $ans in + '~') ans=$HOME ;; + '~/'*) ans=$HOME/${ans#"~/"} + esac + + switch_dir "$ans" || [ -n "$ltype" ] || continue + set_type=normal y=1 y2=1 ltype= + ;; + + /?) + [ "$ltype" = keybinds ] && continue + prompt / $# || continue + ans="${ans##/}" + case $ans in + ''|*//*) continue + esac + set_type=search y=1 y2=1 + ;; + + -?) + switch_dir "$OLDPWD" || [ -n "$ltype" ] || continue + set_type=normal y=1 y2=1 ltype= + ;; + + \~?) + switch_dir "$HOME" || [ -n "$ltype" ] || continue + set_type=normal y=1 y2=1 ltype= + ;; + + \!?) + export SHFM_LEVEL + SHFM_LEVEL=$((SHFM_LEVEL + 1)) + cmd_run "${SHELL:=/bin/sh}" + redraw "$@" + ;; + + \??) set_type=keybinds y=1 y2=1 ;; + + q?) + if [ "$ltype" = keybinds ]; then + set_type=normal y=1 y2=1 ltype= + else + old_tab=$tab_num + prev_tab || exit 0 + case $old_tab in + 1) unset tab1 ;; + 2) unset tab2 ;; + 3) unset tab3 ;; + 4) unset tab4 ;; + esac + fi + ;; + + # handle keys which emit escape sequences + "$esc_c"*) state=1 ;; + '[1') state=2 ;; + *) state=0 ;; + esac + if [ -n "$switched_tabs" ]; then + switched_tabs= + switch_dir "$nwd" || : + case $changed in + pos) + if [ $# -eq 0 ]; then + y=1 y2=1 cur= + else + [ "$y" -gt $# ] && y=$# + [ "$y2" -gt "$y" ] && y2=$y + hist=2 + get_cur "$y" "$@" + redraw "$@" + fi + ;; + + all) + case $ltype in + keybinds) set_type=keybinds ;; + + search*) + set_type=search + ans="${ltype#"search $PWD/"}" + ans="${ans%'*'}" + ;; + + '') set_type=normal + esac + ;; + + *) status_line $# + esac + fi + done +} + +main "$@" >/dev/tty diff --git a/plugins/terminal.koplugin/shfm_opener.sh b/plugins/terminal.koplugin/shfm_opener.sh new file mode 100755 index 000000000..8811dd16d --- /dev/null +++ b/plugins/terminal.koplugin/shfm_opener.sh @@ -0,0 +1,35 @@ +#!/bin/sh -e +# +# open file in application based on file extension + +mime_type=$(file -bi "$1") + +case "${mime_type}" in + application/x*) + ./"$1" + echo "Application done, hit enter to return" + read -r + exit + ;; + + text/x-shellscript*) + ./"$1" + echo "Shellscript done, hit enter to return" + read -r + exit + ;; +esac + +case "$1" in + *.sh) + sh "$1" + echo "Shellscript done, enter to return." + read -r + exit + ;; + + # all other files + *) + "${EDITOR:=vi}" "$1" + ;; +esac diff --git a/plugins/terminal.koplugin/terminputtext.lua b/plugins/terminal.koplugin/terminputtext.lua new file mode 100644 index 000000000..d84cf43a0 --- /dev/null +++ b/plugins/terminal.koplugin/terminputtext.lua @@ -0,0 +1,857 @@ +--[[ +module used for terminal emulator to override InputText +]] + +local InputText = require("ui/widget/inputtext") +local UIManager = require("ui/uimanager") +local dbg = require("dbg") +local logger = require("logger") +local util = require("util") + +local esc = "\027" + +local esc_seq = { + backspace = "\008", + cursor_left = "\027[D", + cursor_right = "\027[C", + cursor_up = "\027[A", + cursor_down = "\027[B", + cursor_pos1 = "\027[7~", + cursor_end = "\027[8~", + page_up = "\027[5~", + page_down = "\027[6~", +} + +local function isNum(char) + if #char ~= 1 then return end + if char:byte() >= ("0"):byte() and char:byte() <= ("9"):byte() then + return true + end +end + +local function isPrintable(ch) + return ch:byte() >= 32 or ch == "\010" or ch == "\013" +end + +local TermInputText = InputText:extend{ + maxr = 40, + maxc = 80, + min_buffer_size = 2 * 40 * 80, -- minimal size of scrollback buffer + strike_callback = nil, + sequence_state = "", + + store_pos_dec = nil, + store_pos_sco = nil, + store_position = nil, -- when entered alternate keypad + + scroll_region_bottom = nil, + scroll_region_top = nil, + scroll_region_line = nil, + + wrap = true, + + alternate_buffer = {}, + save_buffer = {}, +} + +-- disable positioning cursor by tap in emulator mode +function TermInputText:onTapTextBox(arg, ges) + return true +end + +function TermInputText:resize(maxr, maxc) + self.maxr = maxr + self.maxc = maxc + self.min_buffer_size = 2 * self.maxr * self.maxc +end + +-- reduce the size of the buffer, +function TermInputText:trimBuffer(new_size) + if not new_size or new_size < self.min_buffer_size then + new_size = self.min_buffer_size + end + if #self.charlist > new_size then + local old_pos = self.charpos -- for adjusting saved positions + -- remove char from beginning + while #self.charlist > new_size do + table.remove(self.charlist, 1) + self.charpos = self.charpos - 1 + end + -- remove the (rest) of the first line + while self.charlist[1] and self.charlist[1] ~= "\n" do + table.remove(self.charlist, 1) + self.charpos = self.charpos - 1 + end + -- remove newline at the first line + if self.charlist[1] and self.charlist[1] == "\n" then + table.remove(self.charlist, 1) + self.charpos = self.charpos - 1 + end + + -- IMPORTANT: update stored positions if the buffer has to be trimmed + local shift_pos = old_pos - self.charpos + if self.store_position then + self.store_position = self.store_position - shift_pos + if self.store_position < 1 then + self.store_position = 1 + end + end + if self.store_pos_dec then + self.store_pos_dec = self.store_pos_dec - shift_pos + if self.store_pos_dec < 1 then + self.store_pos_dec = 1 + end + end + if self.store_pos_sco then + self.store_pos_sco = self.store_pos_sco - shift_pos + if self.store_pos_sco < 1 then + self.store_pos_sco = 1 + end + end + -- unlikely but this could happen if the cursor is at the beginning + -- and the buffer has to be trimmed + if self.charpos < 1 then + self.charpos = 1 + end + self:initTextBox(table.concat(self.charlist), true) + end +end + +function TermInputText:saveBuffer(buffer) + table.insert(self[buffer], + { + self.charlist, + self.charpos, + self.store_pos_sco, + self.store_position, + self.scroll_region_bottom, + self.scroll_region_top, + self.scroll_region_line, + self.wrap, + }) + self.charlist = {} + self.charpos = 1 + self.store_pos_dec = nil + self.store_pos_sco = nil + self.store_position = nil + self.scroll_region_bottom = nil + self.scroll_region_top = nil + self.scroll_region_line = nil + self.wrap = true +end + +function TermInputText:restoreBuffer(buffer) + local former_buffer = table.remove(self[buffer]) + if type(former_buffer[1]) == "table" then + self.charlist, + self.charpos, + self.store_pos_sco, + self.store_position, + self.scroll_region_bottom, + self.scroll_region_top, + self.scroll_region_line, + self.wrap = unpack(former_buffer) + end +end + +function TermInputText:_helperVT52VT100(cmd, mode, param1, param2, param3) + if cmd == "A" then -- cursor up + param1 = param1 == 0 and 1 or param1 + for i = 1, param1 do + if self.scroll_region_line then + self:scrollRegionDown() + end + self:moveCursorUp(true) + end + return true + elseif cmd == "B" then -- cursor down + param1 = param1 == 0 and 1 or param1 + for i = 1, param1 do + self:moveCursorDown(true) + end + return true + elseif cmd == "C" then -- cursor right + param1 = param1 == 0 and 1 or param1 + for i = 1, param1 do + self:rightChar(true) + end + return true + elseif cmd == "D" then -- cursor left + param1 = param1 == 0 and 1 or param1 + for i = 1, param1 do + self:leftChar(true) + end + return true + elseif cmd == "H" then -- cursor home + param1 = param1 == 0 and 1 or param1 + param2 = param2 == 0 and 1 or param2 + self:moveCursorToRowCol(param1, param2) + if self.scroll_region_line and param1 <= self.scroll_region_bottom + and param1 >= self.scroll_region_top then + self.scroll_region_line = param1 + end + return true + elseif cmd == "J" then -- clear to end of screen + if param1 == 0 then + self:clearToEndOfScreen() + elseif param1 == 1 then + return false --- @todo not implemented + elseif param1 == 2 then + local saved_pos = self.charpos + self:moveCursorToRowCol(1, 1) + self:clearToEndOfScreen() + self.charpos = saved_pos + end + return true + elseif cmd == "K" then -- clear to end of line + self:delToEndOfLine() + return true + elseif cmd == "L" then + if self.scroll_region_line then + self:scrollRegionDown() + end + return true + elseif cmd == "h" and mode == "?" then -- + --- if param2 == 25 then set cursor visible + if param2 == 7 then -- enable wrap around + self.wrap = true + elseif param2 == 47 then -- save screen + self:saveBuffer("save_buffer") + print("xxxxxxxxxxxx save screen") + elseif param2 == 1049 then -- enable alternate buffer + self:saveBuffer("alternate_buffer") + print("xxxxxxxxxxxx enable alternate buffer") + end + return true + elseif cmd == "l" and mode == "?" then -- + --- if param2 == 25 then set cursor invisible + if param2 == 7 then -- enable wrap around + self.wrap = false + elseif param2 == 47 then -- restore screen + self:restoreBuffer("save_buffer") + print("xxxxxxxxxxxx restore screen") + elseif param2 == 1049 then -- disable alternate buffer + self:restoreBuffer("alternate_buffer") + print("xxxxxxxxxxxx disable alternate buffer") + end + return true + elseif cmd == "m" then + -- graphics mode not supported yet(?) + return true + elseif cmd == "n" then + --- @todo + return true + elseif cmd == "r" then + if param2 > 0 and param2 < self.maxr then + self.scroll_region_bottom = param2 + else + self.scroll_region_bottom = nil + end + + if self.scroll_region_bottom and param1 < self.maxr and param1 <= param2 then + self.scroll_region_top = param1 + self.scroll_region_line = 1 + else + self.scroll_region_bottom = nil + self.scroll_region_top = nil + self.scroll_region_line = nil + end + logger.dbg("Terminal: set scroll region", param1, param2, self.scroll_region_top, self.scroll_region_bottom, self.scroll_region_line) + return true + end + return false +end + +function TermInputText:interpretAnsiSeq(text) + local pos = 1 + local param1, param2, param3 = 0, 0, 0 + + while pos <= #text do + local next_byte = text:sub(pos, pos) + if self.sequence_state == "" then + if next_byte == esc then + self.sequence_state = "esc" + elseif isPrintable(next_byte) then + local part = next_byte + -- all bytes up to the next control sequence + while pos < #text and isPrintable(next_byte) do + next_byte = text:sub(pos+1, pos+1) + if next_byte ~= "" and pos < #text and isPrintable(next_byte) then + part = part .. next_byte + pos = pos + 1 + end + end + self:addChars(part, true, true) + elseif next_byte == "\008" then + self.charpos = self.charpos - 1 + end + elseif self.sequence_state == "esc" then + self.sequence_state = "" + if next_byte == "A" then -- cursor up + self:moveCursorUp(true) + elseif next_byte == "B" then -- cursor down + self:moveCursorDown(true) + elseif next_byte == "C" then -- cursor right + self:rightChar(true) + elseif next_byte == "D" then -- cursor left + self:leftChar(true) + elseif next_byte == "F" then -- enter graphics mode + logger.dbg("Terminal: enter graphics mode not supported") + elseif next_byte == "G" then -- exit graphics mod + logger.dbg("Terminal: leave graphics mode not supported") + elseif next_byte == "H" then -- cursor home + self:moveCursorToRowCol(1, 1) + elseif next_byte == "I" then -- reverse line feed (cursor up and insert line) + self:reverseLineFeed(true) + elseif next_byte == "J" then -- clear to end of screen + self:clearToEndOfScreen() + elseif next_byte == "K" then -- clear to end of line + self:delToEndOfLine() + elseif next_byte == "L" then -- insert line + logger.dbg("Terminal: insert not supported") + elseif next_byte == "M" then -- remove line + logger.dbg("Terminal: remove line not supported") + elseif next_byte == "Y" then -- set cursor pos (row, col) + self.sequence_state = "escY" + elseif next_byte == "Z" then -- ident(ify) + self.strike_callback("\027/K") -- identify as VT52 without printer + elseif next_byte == "=" then -- alternate keypad + self:enterAlternateKeypad() + elseif next_byte == ">" then -- exit alternate keypad + self:exitAlternateKeypad() + elseif next_byte == "[" then + self.sequence_state = "CSI1" + elseif next_byte == "7" then + self.store_pos_dec = self.charpos + elseif next_byte == "8" then + self.charpos = self.store_pos_dec + end + elseif self.sequence_state == "escY" then + param1 = next_byte + self.sequence_state = "escYrow" + elseif self.sequence_state == "escYrow" then + param2 = next_byte + -- row and column are offsetted with 32 (' ') + if param1 ~= 0 and param2 ~= 0 then + local row = param1 and (param1:byte() - (" "):byte() + 1) or 1 + local col = param2 and (param2:byte() - (" "):byte() + 1) or 1 + self:moveCursorToRowCol(row, col) + param1, param2, param3 = 0, 0, 0 + end + self.sequence_state = "" + elseif self.sequence_state == "CSI1" then + if next_byte == "s" then -- save cursor pos + self.store_pos_sco = self.charpos + elseif next_byte == "u" then -- restore cursor pos + self.charpos = self.store_pos_sco + elseif next_byte == "?" then + self.sequence_mode = "?" + self.sequence_state = "escParam2" + elseif isNum(next_byte) then + param1 = param1 * 10 + next_byte:byte() - ("0"):byte() + else + if next_byte == ";" then + self.sequence_state = "escParam2" + else + pos = pos - 1 + self.sequence_state = "escOtherCmd" + end + end + elseif self.sequence_state == "escParam2" then + if isNum(next_byte) then + param2 = param2 * 10 + next_byte:byte() - ("0"):byte() + else + if next_byte == ";" then + self.sequence_state = "escParam3" + else + pos = pos - 1 + self.sequence_state = "escOtherCmd" + end + end + elseif self.sequence_state == "escParam3" then + if isNum(next_byte) then + param3 = param3 * 10 + next_byte:byte() - ("0"):byte() + else + pos = pos - 1 + self.sequence_state = "escOtherCmd" + end + elseif self.sequence_state == "escOtherCmd" then + if not self:_helperVT52VT100(next_byte, self.sequence_mode, param1, param2, param3) then + -- drop other VT100 sequences + logger.info("Terminal: ANSI-final: not supported", next_byte, + next_byte:byte(), next_byte, param1, param2, param3) + end + param1, param2, param3 = 0, 0, 0 + self.sequence_state = "" + self.sequence_mode = "" + else + logger.dbg("Terminal: detected error in esc sequence, not my fault.") + self.sequence_state = "" + end -- self.sequence_state + + pos = pos + 1 + end + + self:initTextBox(table.concat(self.charlist), true) +end + +function TermInputText:scrollRegionDown(column) + column = column or 1 + if self.scroll_region_line > self.scroll_region_top then + self.scroll_region_line = self.scroll_region_line - 1 + else -- scroll down + local pos = self.charpos + for i = self.scroll_region_line, self.scroll_region_bottom do + while pos > 1 and self.charlist[pos] ~= "\n" do + pos = pos + 1 + end + if pos < #self.charlist then + pos = pos + 1 + end + end + pos = pos - 1 + + table.remove(self.charlist, pos) + while self.charlist[pos] ~= "\n" do + table.remove(self.charlist, pos) + end + + pos = self.charpos + for i = column, self.maxc - column + 1 do + table.insert(self.charlist, pos, ".") + pos = pos + 1 + end + table.insert(self.charlist, pos, "\n") + end +end + +function TermInputText:scrollRegionUp(column) + column = column or 1 + if self.scroll_region_line < self.scroll_region_bottom then + self.scroll_region_line = self.scroll_region_line + 1 + else -- scroll up + local pos = self.charpos + for i = self.scroll_region_line, self.scroll_region_top + 1, -1 do + while pos > 1 and self.charlist[pos] ~= "\n" do + pos = pos - 1 + end + if pos > 1 then + pos = pos - 1 + end + end + pos = pos + 1 + + table.remove(self.charlist, pos) + self.charpos = self.charpos - 1 + pos = pos - 1 + while pos > 0 and self.charlist[pos] ~= "\n" do + table.remove(self.charlist, pos) + pos = pos - 1 + end + + pos = self.charpos + 1 + for i = column, self.maxc - column do + table.insert(self.charlist, pos, " ") + pos = pos + 1 + end + table.insert(self.charlist, pos, "\n") + for i = 1, column - 1 do + table.insert(self.charlist, pos, " ") + pos = pos + 1 + end + end +end + +function TermInputText:addChars(chars, skip_callback, skip_table_concat) + -- the same as in inputtext.lua + if not chars then + -- VirtualKeyboard:addChar(key) gave us 'nil' once (?!) + -- which would crash table.concat() + return + end + if self.enter_callback and chars == "\n" and not skip_callback then + UIManager:scheduleIn(0.3, function() self.enter_callback() end) + return + end + + -- this is an addon to inputtext.lua + if self.strike_callback and not skip_callback then + self.strike_callback(chars) + return + end + + -- the same as in inputtext.lua + if self.readonly or not self:isTextEditable(true) then + return + end + + self.is_text_edited = true + if #self.charlist == 0 then -- widget text is empty or a hint text is displayed + self.charpos = 1 -- move cursor to the first position + end + + -- this is a modification of inputtext.lua + local chars_list = util.splitToChars(chars) -- for UTF8 + for i = 1, #chars_list do + if chars_list[i] == "\n" then + -- detect current column + local pos = self.charpos + while pos > 0 and self.charlist[pos] ~= "\n" do + pos = pos - 1 + end + local column = self.charpos - pos + + if self.scroll_region_line then + self:scrollRegionUp(column) + end + + -- go to EOL + while self.charlist[self.charpos] and self.charlist[self.charpos] ~= "\n" do + self.charpos = self.charpos + 1 + end + + if not self.charlist[self.charpos] then -- add new line if necessary + table.insert(self.charlist, self.charpos, "\n") + self.charpos = self.charpos + 1 + end + + -- go to column in next line + for j = 1, column-1 do + if not self.charlist[self.charpos] then + table.insert(self.charlist, self.charpos, " ") + self.charpos = self.charpos + 1 + else + break + end + end + + if self.charlist[self.charpos] then + self.charpos = self.charpos + 1 + end + + -- fill line + pos = self.charpos + for j = column, self.maxc do + if not self.charlist[pos] then + table.insert(self.charlist, pos, " ") + end + pos = pos + 1 + end + if not self.charlist[pos] then + table.insert(self.charlist, pos, "\n") + end + elseif chars_list[i] == "\r" then + if self.charlist[self.charpos] == "\n" then + self.charpos = self.charpos - 1 + end + while self.charpos >=1 and self.charlist[self.charpos] ~= "\n" do + self.charpos = self.charpos - 1 + end + self.charpos = self.charpos + 1 + elseif chars_list[i] == "\b" then + self.charpos = self.charpos - 1 + else + if self.wrap then + if self.charlist[self.charpos] == "\n" then + self.charpos = self.charpos + 1 + for j = 0, self.maxc-1 do + if not self.charlist[self.charpos + j] then + table.insert(self.charlist, self.charpos + j, " ") + end + end + end + else + local column = 1 + local pos = self.charpos + while pos > 0 and self.charlist[pos] ~= "\n" do + pos = pos - 1 + column = column + 1 + end + if self.charlist[self.charpos] == "\n" or column > self.maxc then + self.charpos = self.charpos - 1 + end + end + table.remove(self.charlist, self.charpos) + table.insert(self.charlist, self.charpos, chars_list[i]) + self.charpos = self.charpos + 1 + end + end + + -- the same as in inputtext.lua + if not skip_table_concat then + self:initTextBox(table.concat(self.charlist), true) + end +end +dbg:guard(TermInputText, "addChars", + function(self, chars) + assert(type(chars) == "string", + "TermInputText: Wrong chars value type (expected string)!") + end) + +function TermInputText:enterAlternateKeypad() + self.store_position = self.charpos + self:formatTerminal(true) +end + +function TermInputText:exitAlternateKeypad() + if self.store_position then + self.charpos = self.store_position + self.store_position = nil + -- clear the alternate keypad buffer + while self.charlist[self.charpos] do + table.remove(self.charlist, self.charpos) + end + end +end + +--- generates a "tty-matrix" +-- @param maxr number of rows +-- @param maxc number of columns +-- @param clear if true, fill the matrix ' ' +function TermInputText:formatTerminal(clear) + local i = self.store_position or 1 + -- so we end up in a maxr x maxc array for positioning + for r = 1, self.maxr do + for c = 1, self.maxc do + if not self.charlist[i] then -- end of text + table.insert(self.charlist, i, "\n") + end + + if self.charlist[i] ~= "\n" then + if clear then + self.charlist[i] = ' ' + end + else + table.insert(self.charlist, i, ' ') + end + i = i + 1 + end + if self.charlist[i] ~= "\n" then + table.insert(self.charlist, i, "\n") + end + i = i + 1 -- skip newline + end +-- table.remove(self.charlist, i - 1) +end + +function TermInputText:moveCursorToRowCol(r, c) + self:formatTerminal() + + local cur_r, cur_c = 1, 0 + local i = self.store_position or 1 + while i < #self.charlist do + if self.charlist[i] ~= "\n" then + cur_c = cur_c + 1 + else + cur_c = 0 -- as we are at the last NL + cur_r = cur_r + 1 + end + self.charpos = i + if cur_r == r and cur_c == c then + break + end + i = i + 1 + end + self:moveCursorToCharPos(self.charpos) +end + +function TermInputText:clearToEndOfScreen() + local pos = self.charpos + while pos <= #self.charlist do + if self.charlist[pos] ~= "\n" then + self.charlist[pos] = " " + end + pos = pos + 1 + end + self.is_text_edited = true + self:initTextBox(table.concat(self.charlist)) +-- self:moveCursorToCharPos(self.charpos) +end + +function TermInputText:delToEndOfLine() + if self.readonly or not self:isTextEditable(true) then + return + end + local cur_pos = self.charpos + -- self.charlist[self.charpos] is the char after the cursor + while self.charlist[cur_pos] and self.charlist[cur_pos] ~= "\n" do + self.charlist[cur_pos] = " " + cur_pos = cur_pos + 1 + end + self:initTextBox(table.concat(self.charlist)) +end + +function TermInputText:reverseLineFeed(skip_callback) + if self.strike_callback and not skip_callback then + self.strike_callback(esc_seq.page_down) + return + end + if self.charpos > 1 and self.charlist[self.charpos] == "\n" then + self.charpos = self.charpos - 1 + end + local cur_col = 0 + while self.charpos > 1 and self.charlist[self.charpos] ~= "\n" do + self.charpos = self.charpos - 1 + cur_col = cur_col + 1 + end + if self.charpos > 1 then + self.charpos = self.charpos + 1 + end + for i = 1, 80 do + table.insert(self.charlist, self.charpos, " ") + end +end + +------------------------------------------------------------------ +-- overridden InputText methods -- +------------------------------------------------------------------ + +function TermInputText:leftChar(skip_callback) + if self.charpos == 1 then return end + if self.strike_callback and not skip_callback then + self.strike_callback(esc_seq.cursor_left) + return + end + local left_char = self.charlist[self.charpos - 1] + if not left_char or left_char == "\n" then + return + end + --InputText.leftChar(self) + self.charpos = self.charpos - 1 +end + +function TermInputText:rightChar(skip_callback) + if self.strike_callback and not skip_callback then + self.strike_callback(esc_seq.cursor_right) + return + end + if self.charpos > #self.charlist then return end + local right_char = self.charlist[self.charpos + 1] + if not right_char and right_char == "\n" then + return + end + InputText.rightChar(self) +end + +function TermInputText:moveCursorUp() + local pos = self.charpos + while self.charlist[pos] and self.charlist[pos] ~= "\n" do + pos = pos - 1 + end + local column = self.charpos - pos + pos = pos - 1 + while self.charlist[pos] and self.charlist[pos] ~= "\n" do + pos = pos - 1 + end + self.charpos = pos + column + self:moveCursorToCharPos(self.charpos) +end + +function TermInputText:moveCursorDown() + local pos = self.charpos + + -- detect current column + while pos > 0 and self.charlist[pos] ~= "\n" do + pos = pos - 1 + end + local column = self.charpos - pos + + while self.charlist[self.charpos] and self.charlist[self.charpos] ~= "\n" do + self.charpos = self.charpos + 1 + end + self.charpos = self.charpos + 1 + for i = 1, column-1 do + if self.charlist[pos+i] or self.charlist[pos+i] ~= "\n" then + self.charpos = self.charpos + 1 + else + break + end + end + self:moveCursorToCharPos(self.charpos) +end + +function TermInputText:delChar() + if self.readonly or not self:isTextEditable(true) then + return + end + if self.charpos == 1 then return end + + if self.strike_callback then + self.strike_callback(esc_seq.backspace) + return + end + InputText.delChar(self) +end + +function TermInputText:delToStartOfLine() + return +end + +function TermInputText:scrollDown(skip_callback) + if self.strike_callback and not skip_callback then + self.strike_callback(esc_seq.page_down) + return + end + InputText.scrollDown(self) +end + +function TermInputText:scrollUp(skip_callback) + if self.strike_callback and not skip_callback then + self.strike_callback(esc_seq.page_up) + return + end + InputText.scrollUp(self) +end + +function TermInputText:goToStartOfLine(skip_callback) + if self.strike_callback then + if not skip_callback then + self.strike_callback(esc_seq.cursor_pos1) + else + if self.charlist[self.charpos] == "\n" then + self.charpos = self.charpos - 1 + end + while self.charpos >=1 and self.charlist[self.charpos] ~= "\n" do + self.charpos = self.charpos - 1 + end + self.charpos = self.charpos + 1 + self.text_widget:moveCursorToCharPos(self.charpos) + end + return + end + InputText.goToStartOfLine(self) +end + +function TermInputText:goToEndOfLine(skip_callback) + if self.strike_callback then + if not skip_callback then + self.strike_callback(esc_seq.cursor_end) + else + while self.charpos <= #self.charlist and self.charlist[self.charpos] ~= "\n" do + self.charpos = self.charpos + 1 + end + end + self.text_widget:moveCursorToCharPos(self.charpos) + return + end + InputText.goToEndOfLine(self) +end + +function TermInputText:upLine(skip_callback) + if self.strike_callback and not skip_callback then + self.strike_callback(esc_seq.cursor_up) + return + end + InputText.upLine(self) +end + +function TermInputText:downLine(skip_callback) + if #self.charlist == 0 then return end -- Avoid cursor moving within a hint. + if self.strike_callback and not skip_callback then + self.strike_callback(esc_seq.cursor_down) + return + end + InputText.downLine(self) +end + +return TermInputText