From 6e35e683dd250b5fdbffac33e6e7aadddeba4d2b Mon Sep 17 00:00:00 2001 From: poire-z Date: Mon, 6 Aug 2018 21:16:30 +0200 Subject: [PATCH] Text editor plugin, InputDialog enhancements (#4135) This plugin mostly sets up a "Text editor>" submenu, that allows browsing files, creating a new file, and managing a history of previously opened file for easier re-opening. It restore previous scroll and cursor positions on re-opening. Additional "Check lua" syntax button is added when editing a .lua file, and prevent saving if errors. The text editing is mainly provided by the enhanced InputDialog. InputDialog: added a few more options, the main one being 'save_callback', which will add a Save and Close buttons and manage saving/discarding/exiting. If "fullscreen" and "add_nav_bar", will add a show/hide keyboard button to it. Moved the preset buttons setup code in their own InputDialog methods for clarity of the main init code. Buttons are now enabled/disabled depending on context for feedback (eg: Save is disabled as long as text has not been modified). Added util.checkLuaSyntax(lua_string), might be useful elsewhere. --- .../ui/elements/filemanager_menu_order.lua | 1 + frontend/ui/elements/reader_menu_order.lua | 1 + frontend/ui/widget/buttontable.lua | 8 + frontend/ui/widget/inputdialog.lua | 432 +++++++++++++-- frontend/ui/widget/inputtext.lua | 24 +- frontend/ui/widget/logindialog.lua | 2 +- frontend/ui/widget/multiinputdialog.lua | 2 +- frontend/ui/widget/openwithdialog.lua | 2 +- frontend/ui/widget/scrolltextwidget.lua | 3 + frontend/util.lua | 16 +- plugins/texteditor.koplugin/main.lua | 498 ++++++++++++++++++ 11 files changed, 927 insertions(+), 62 deletions(-) create mode 100644 plugins/texteditor.koplugin/main.lua diff --git a/frontend/ui/elements/filemanager_menu_order.lua b/frontend/ui/elements/filemanager_menu_order.lua index ad40660da..7e04cdc57 100644 --- a/frontend/ui/elements/filemanager_menu_order.lua +++ b/frontend/ui/elements/filemanager_menu_order.lua @@ -51,6 +51,7 @@ local order = { "read_timer", "news_downloader", "send2ebook", + "text_editor", "----------------------------", "more_plugins", "----------------------------", diff --git a/frontend/ui/elements/reader_menu_order.lua b/frontend/ui/elements/reader_menu_order.lua index 9a92782a5..eaf7452f5 100644 --- a/frontend/ui/elements/reader_menu_order.lua +++ b/frontend/ui/elements/reader_menu_order.lua @@ -72,6 +72,7 @@ local order = { "zsync", "news_downloader", "send2ebook", + "text_editor", "----------------------------", "more_plugins", }, diff --git a/frontend/ui/widget/buttontable.lua b/frontend/ui/widget/buttontable.lua index 6c52d8b40..50245b75c 100644 --- a/frontend/ui/widget/buttontable.lua +++ b/frontend/ui/widget/buttontable.lua @@ -29,6 +29,7 @@ local ButtonTable = FocusManager:new{ function ButtonTable:init() self.selected = { x = 1, y = 1 } self.buttons_layout = {} + self.button_by_id = {} self.container = VerticalGroup:new{ width = self.width } table.insert(self, self.container) if self.zero_sep then @@ -61,6 +62,9 @@ function ButtonTable:init() text_font_size = self.button_font_size, show_parent = self.show_parent, } + if btn_entry.id then + self.button_by_id[btn_entry.id] = button + end local button_dim = button:getSize() local vertical_sep = LineWidget:new{ background = Blitbuffer.COLOR_GREY, @@ -121,4 +125,8 @@ function ButtonTable:onSelectByKeyPress() end end +function ButtonTable:getButtonById(id) + return self.button_by_id[id] -- nil if not found +end + return ButtonTable diff --git a/frontend/ui/widget/inputdialog.lua b/frontend/ui/widget/inputdialog.lua index c992eb27c..f5195bb81 100644 --- a/frontend/ui/widget/inputdialog.lua +++ b/frontend/ui/widget/inputdialog.lua @@ -46,6 +46,35 @@ To get a full screen text editor, use: add_scroll_buttons = true, add_nav_bar = true, +To add |Save|Close| buttons, use: + save_callback = function(content, closing) + ...deal with the edited content... + if closing then + UIManager:nextTick( stuff to do when InputDialog closed if any ) + end + return nil -- sucess, default notification shown + return true, success_notif_text + return false, error_infomsg_text + end +To additionally add a Reset button and have |Reset|Save|Close|, use: + reset_callback = function() + return original_content -- success + return original_content, success_notif_text + return nil, error_infomsg_text + end +If you don't need more buttons than these, use these options for consistency +between dialogs, and don't provide any buttons. +Text used on these buttons and their messages and notifications can be +changed by providing alternative text with these additional options: + reset_button_text + save_button_text + close_button_text + close_unsaved_confirm_text + close_cancel_button_text + close_discard_button_text + close_save_button_text + close_discarded_notif_text + If it would take the user more than half a minute to recover from a mistake, a "Cancel" button must be added to the dialog. The cancellation button should be kept on the left and the button executing the action on the right. @@ -63,10 +92,13 @@ local Device = require("device") local Font = require("ui/font") local FrameContainer = require("ui/widget/container/framecontainer") local Geom = require("ui/geometry") +local InfoMessage = require("ui/widget/infomessage") local InputContainer = require("ui/widget/container/inputcontainer") local InputText = require("ui/widget/inputtext") local LineWidget = require("ui/widget/linewidget") local MovableContainer = require("ui/widget/container/movablecontainer") +local MultiConfirmBox = require("ui/widget/multiconfirmbox") +local Notification = require("ui/widget/notification") local RenderText = require("ui/rendertext") local Size = require("ui/size") local TextBoxWidget = require("ui/widget/textboxwidget") @@ -75,6 +107,7 @@ local UIManager = require("ui/uimanager") local VerticalGroup = require("ui/widget/verticalgroup") local VerticalSpan = require("ui/widget/verticalspan") local Screen = Device.screen +local _ = require("gettext") local InputDialog = InputContainer:new{ is_always_active = true, @@ -85,13 +118,33 @@ local InputDialog = InputContainer:new{ buttons = nil, input_type = nil, enter_callback = nil, + 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 fullscreen = false, -- adjust to full screen minus keyboard condensed = false, -- true will prevent adding air and balance between elements add_scroll_buttons = false, -- add scroll Up/Down buttons to first row of buttons add_nav_bar = false, -- append a row of page navigation buttons - -- note that the text widget can be scrolled with Swipe North/South even when no button + -- note that the text widget can be scrolled with Swipe North/South even when no button + keyboard_hidden = false, -- start with keyboard hidden in full fullscreen mode + -- needs add_nav_bar to have a Show keyboard button to get it back + + -- If save_callback provided, a Save and a Close buttons will be added to the first row + -- if reset_callback provided, a Reset button will be added (before Save) to the first row + save_callback = nil, -- Called with the input text content when Save (and true as 2nd arg + -- if closing, false if non-closing Save). + -- Should return nil or true on success, false on failure. + -- (This save_callback can do some syntax check before saving) + reset_callback = nil, -- Called with no arg, should return the original content on success, + -- nil on failure. + -- Both these callbacks can return a string as a 2nd return value. + -- This string is then shown: + -- - on success: as the notification text instead of the default one + -- - on failure: in an InfoMessage + + -- For use by TextEditor plugin: + view_pos_callback = nil, -- Called with no arg to get initial top_line_num/charpos, + -- called with (top_line_num, charpos) to give back position on close. -- movable = true, -- set to false if movable gestures conflicts with subwidgets gestures -- for now, too much conflicts between InputText and MovableContainer, and @@ -116,6 +169,15 @@ local InputDialog = InputContainer:new{ input_margin = Size.margin.default, button_padding = Size.padding.default, border_size = Size.border.window, + + -- for internal use + _text_modified = false, -- previous known modified status + _top_line_num = nil, + _charpos = nil, + _buttons_edit_callback = nil, + _buttons_scroll_callback = nil, + _buttons_backup_done = false, + _buttons_backup = nil, } function InputDialog:init() @@ -123,6 +185,7 @@ function InputDialog:init() self.movable = false self.border_size = 0 self.width = Screen:getWidth() - 2*self.border_size + self.covers_fullscreen = true -- hint for UIManager:_repaint() else self.width = self.width or Screen:getWidth() * 0.8 end @@ -131,6 +194,9 @@ function InputDialog:init() else self.text_width = self.text_width or self.width * 0.9 end + if self.readonly then -- hide keyboard if we can't edit + self.keyboard_hidden = true + end -- Title & description local title_width = RenderText:sizeUtf8Text(0, self.width, @@ -142,7 +208,7 @@ function InputDialog:init() self.title = RenderText:getSubTextByWidth(self.title, self.title_face, self.width - indicator_w, true) .. indicator end - self.title = FrameContainer:new{ + self.title_widget = FrameContainer:new{ padding = self.title_padding, margin = self.title_margin, bordersize = 0, @@ -191,54 +257,19 @@ function InputDialog:init() end -- Buttons - if self.add_nav_bar then - if not self.buttons then - self.buttons = {} - end - local nav_bar = {} - table.insert(self.buttons, nav_bar) - table.insert(nav_bar, { - text = "⇱", - callback = function() - self._input_widget:scrollToTop() - end, - }) - table.insert(nav_bar, { - text = "⇲", - callback = function() - self._input_widget:scrollToBottom() - end, - }) - table.insert(nav_bar, { - text = "△", - callback = function() - self._input_widget:scrollUp() - end, - }) - table.insert(nav_bar, { - text = "▽", - callback = function() - self._input_widget:scrollDown() - end, - }) - elseif self.add_scroll_buttons then - if not self.buttons then - self.buttons = {{}} - end - -- Add them to the end of first row - table.insert(self.buttons[1], { - text = "△", - callback = function() - self._input_widget:scrollUp() - end, - }) - table.insert(self.buttons[1], { - text = "▽", - callback = function() - self._input_widget:scrollDown() - end, - }) + -- In case of re-init(), keep backup of original buttons and restore them + self:_backupRestoreButtons() + -- If requested, add predefined buttons alongside provided ones + if self.save_callback then + -- If save_callback provided, adds (Reset) / Save / Close buttons + self:_addSaveCloseButtons() end + if self.add_nav_bar then -- Home / End / Up / Down buttons + self:_addScrollButtons(true) + elseif self.add_scroll_buttons then -- Up / Down buttons + self:_addScrollButtons(false) + end + -- Buttons Table self.button_table = ButtonTable:new{ width = self.width - 2*self.button_padding, button_font_face = "cfont", @@ -269,12 +300,15 @@ function InputDialog:init() local text_height = input_widget:getTextHeight() local line_height = input_widget:getLineHeight() local input_pad_height = input_widget:getSize().h - text_height - local keyboard_height = input_widget:getKeyboardDimen().h + local keyboard_height = 0 + if not self.keyboard_hidden then + keyboard_height = input_widget:getKeyboardDimen().h + end input_widget:free() -- Find out available height local available_height = Screen:getHeight() - 2*self.border_size - - self.title:getSize().h + - self.title_widget:getSize().h - self.title_bar:getSize().h - self.description_widget:getSize().h - vspan_before_input_text:getSize().h @@ -297,6 +331,11 @@ function InputDialog:init() self.text_height = text_height end end + if self.view_pos_callback then + -- Get initial cursor and top line num from callback + -- (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{ text = self.input, hint = self.input_hint, @@ -317,9 +356,15 @@ function InputDialog:init() end end end, + 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, cursor_at_end = self.cursor_at_end, + readonly = self.readonly, parent = self, + is_text_edited = self._text_modified, + top_line_num = self._top_line_num, + charpos = self._charpos, } if self.allow_newline then -- remove any enter_callback self._input_widget.enter_callback = nil @@ -328,6 +373,15 @@ function InputDialog:init() --little hack to piggyback on the layout of the button_table to handle the new InputText table.insert(self.button_table.layout, 1, {self._input_widget}) end + -- Complementary setup for some of our added buttons + if self.save_callback then + local save_button = self.button_table:getButtonById("save") + if self.readonly then + save_button:setText(_("Read only"), save_button.width) + elseif not self._input_widget:isTextEditable() then + save_button:setText(_("Not editable"), save_button.width) + end + end -- Final widget self.dialog_frame = FrameContainer:new{ @@ -338,7 +392,7 @@ function InputDialog:init() background = Blitbuffer.COLOR_WHITE, VerticalGroup:new{ align = "left", - self.title, + self.title_widget, self.title_bar, self.description_widget, vspan_before_input_text, @@ -359,10 +413,12 @@ function InputDialog:init() self.dialog_frame, } end + local keyboard_height = self.keyboard_hidden and 0 + or self._input_widget:getKeyboardDimen().h self[1] = CenterContainer:new{ dimen = Geom:new{ w = Screen:getWidth(), - h = Screen:getHeight() - self._input_widget:getKeyboardDimen().h, + h = Screen:getHeight() - keyboard_height, }, frame } @@ -407,11 +463,279 @@ function InputDialog:onCloseWidget() end function InputDialog:onShowKeyboard() - self._input_widget:onShowKeyboard() + if not self.readonly and not self.keyboard_hidden then + self._input_widget:onShowKeyboard() + end end function InputDialog:onClose() + -- Remember current view & position in case of re-init + self._top_line_num = self._input_widget.top_line_num + self._charpos = self._input_widget.charpos + if self.view_pos_callback then + -- Give back top line num and cursor position + self.view_pos_callback(self._top_line_num, self._charpos) + end self._input_widget:onCloseKeyboard() end +function InputDialog:refreshButtons() + -- Using what ought to be enough: + -- return "ui", self.button_table.dimen + -- causes 2 non-intersecting refreshes (because if our buttons + -- change, the text widget did) that may sometimes cause + -- the button_table to become white. + -- Safer to refresh the whole widget so the refreshes can + -- be merged into one. + UIManager:setDirty(self, function() + return "ui", self.dialog_frame.dimen + end) +end + +function InputDialog:_backupRestoreButtons() + -- In case of re-init(), keep backup of original buttons and restore them + if self._buttons_backup_done then + -- Move backup and override current, and re-create backup from original, + -- to avoid duplicating the copy code) + self.buttons = self._buttons_backup -- restore (we may restore 'nil') + end + if self.buttons then -- (re-)create backup + self._buttons_backup = {} -- deep copy, except for the buttons themselves + for i, row in ipairs(self.buttons) do + if row then + local row_copy = {} + self._buttons_backup[i] = row_copy + for j, b in ipairs(row) do + row_copy[j] = b + end + end + end + end + self._buttons_backup_done = true +end + +function InputDialog:_addSaveCloseButtons() + if not self.buttons then + self.buttons = {{}} + end + -- Add them to the end of first row + local row = self.buttons[1] + local button = function(id) -- shortcut for more readable code + return self.button_table:getButtonById(id) + end + -- Callback to enable/disable Reset/Save buttons, for feedback when text modified + self._buttons_edit_callback = function(edited) + if self._text_modified and not edited then + self._text_modified = false + button("save"):disable() + if button("reset") then button("reset"):disable() end + self:refreshButtons() + elseif edited and not self._text_modified then + self._text_modified = true + button("save"):enable() + if button("reset") then button("reset"):enable() end + self:refreshButtons() + end + end + if self.reset_callback then + -- if reset_callback provided, add button to restore + -- test to some previous state + table.insert(row, { + text = self.reset_button_text or _("Reset"), + id = "reset", + enabled = self._text_modified, + callback = function() + -- Wrapped via Trapper, to allow reset_callback to use Trapper + -- to show progress or ask questions while getting original content + require("ui/trapper"):wrap(function() + local content, msg = self.reset_callback() + if content then + self:setInputText(content) + self._buttons_edit_callback(false) + UIManager:show(Notification:new{ + text = msg or _("Text reset"), + timeout = 2 + }) + else -- nil content, assume failure and show msg + if msg ~= false then -- false allows for no InfoMessage + UIManager:show(InfoMessage:new{ + text = msg or _("Resetting failed."), + }) + end + end + end) + end, + }) + end + table.insert(row, { + text = self.save_button_text or _("Save"), + id = "save", + enabled = self._text_modified, + callback = function() + -- Wrapped via Trapper, to allow save_callback to use Trapper + -- to show progress or ask questions while saving + require("ui/trapper"):wrap(function() + if self._text_modified then + local success, msg = self.save_callback(self:getInputText()) + if success == false then + if msg ~= false then -- false allows for no InfoMessage + UIManager:show(InfoMessage:new{ + text = msg or _("Saving failed."), + }) + end + else -- nil or true + self._buttons_edit_callback(false) + UIManager:show(Notification:new{ + text = msg or _("Saved"), + timeout = 2 + }) + end + end + end) + end, + }) + table.insert(row, { + text = self.close_button_text or _("Close"), + id = "close", + callback = function() + if self._text_modified then + UIManager:show(MultiConfirmBox:new{ + text = self.close_unsaved_confirm_text or _("You have unsaved changes."), + cancel_text = self.close_cancel_button_text or _("Cancel"), + choice1_text = self.close_discard_button_text or _("Discard"), + choice1_callback = function() + UIManager:close(self) + UIManager:show(Notification:new{ + text = self.close_discarded_notif_text or _("Changes discarded"), + timeout = 2 + }) + end, + choice2_text = self.close_save_button_text or _("Save"), + choice2_callback = function() + -- Wrapped via Trapper, to allow save_callback to use Trapper + -- to show progress or ask questions while saving + require("ui/trapper"):wrap(function() + local success, msg = self.save_callback(self:getInputText(), true) + if success == false then + if msg ~= false then -- false allows for no InfoMessage + UIManager:show(InfoMessage:new{ + text = msg or _("Saving failed."), + }) + end + else -- nil or true + UIManager:close(self) + UIManager:show(Notification:new{ + text = msg or _("Saved"), + timeout = 2 + }) + end + end) + end, + }) + else + -- Not modified, exit without any message + UIManager:close(self) + end + end, + }) +end + +function InputDialog:_addScrollButtons(nav_bar) + local row + if nav_bar then -- Add Home / End / Up / Down buttons as a last row + if not self.buttons then + self.buttons = {} + end + row = {} -- Empty additional buttons row + table.insert(self.buttons, row) + else -- Add the Up / Down buttons to the first row + if not self.buttons then + self.buttons = {{}} + end + row = self.buttons[1] + end + if nav_bar then -- Add the Home & End buttons + -- Also add Keyboard hide/show button if we can + if self.fullscreen and not self.readonly then + table.insert(row, { + text = self.keyboard_hidden and "↑⌨" or "↓⌨", + id = "keyboard", + callback = function() + self.keyboard_hidden = not self.keyboard_hidden + self.input = self:getInputText() -- re-init with up-to-date text + self:onClose() -- will close keyboard and save view position + self:free() + self:init() + if not self.keyboard_hidden then + self:onShowKeyboard() + end + end, + }) + end + table.insert(row, { + text = "⇱", + id = "top", + callback = function() + self._input_widget:scrollToTop() + end, + }) + table.insert(row, { + text = "⇲", + id = "bottom", + callback = function() + self._input_widget:scrollToBottom() + end, + }) + end + -- Add the Up & Down buttons + table.insert(row, { + text = "△", + id = "up", + callback = function() + self._input_widget:scrollUp() + end, + }) + table.insert(row, { + text = "▽", + id = "down", + callback = function() + self._input_widget:scrollDown() + end, + }) + -- Callback to enable/disable buttons, for at-top/at-bottom feedback + local prev_at_top = false -- Buttons were created enabled + local prev_at_bottom = false + local button = function(id) -- shortcut for more readable code + return self.button_table:getButtonById(id) + end + self._buttons_scroll_callback = function(low, high) + local changed = false + if prev_at_top and low > 0 then + button("up"):enable() + if button("top") then button("top"):enable() end + prev_at_top = false + changed = true + elseif not prev_at_top and low <= 0 then + button("up"):disable() + if button("top") then button("top"):disable() end + prev_at_top = true + changed = true + end + if prev_at_bottom and high < 1 then + button("down"):enable() + if button("bottom") then button("bottom"):enable() end + prev_at_bottom = false + changed = true + elseif not prev_at_bottom and high >= 1 then + button("down"):disable() + if button("bottom") then button("bottom"):disable() end + prev_at_bottom = true + changed = true + end + if changed then + self:refreshButtons() + end + end +end + return InputDialog diff --git a/frontend/ui/widget/inputtext.lua b/frontend/ui/widget/inputtext.lua index 7a6f8ddde..31750d1d8 100644 --- a/frontend/ui/widget/inputtext.lua +++ b/frontend/ui/widget/inputtext.lua @@ -28,6 +28,8 @@ local InputText = InputContainer:new{ scroll = false, -- whether to allow scrolling (will be set to true if no height provided) focused = true, parent = nil, -- parent dialog that will be set dirty + edit_callback = nil, -- called with true when text modified, false on init or text re-set + scroll_callback = nil, -- called with (low, high) when view is scrolled (cf ScrollTextWidget) width = nil, height = nil, -- when nil, will be set to original text height (possibly @@ -209,9 +211,14 @@ function InputText:init() -- text_type changes from "password" to "text" when we toggle password self.is_password_type = true end + -- Beware other cases where implicit conversion to text may be done + -- at some point, but checkTextEditability() would say "not editable". + if self.input_type == "number" and type(self.text) == "number" then + -- checkTextEditability() fails if self.text stays not a string + self.text = tostring(self.text) + end self:initTextBox(self.text) self:checkTextEditability() - self.is_text_edited = false if self.readonly ~= true then self:initKeyboard() self:initEventListener() @@ -314,6 +321,7 @@ function InputText:initTextBox(text, char_added) width = self.width, height = self.height, dialog = self.parent, + scroll_callback = self.scroll_callback, } else self.text_widget = TextBoxWidget:new{ @@ -356,6 +364,9 @@ function InputText:initTextBox(text, char_added) UIManager:setDirty(self.parent, function() return "ui", self.dimen end) + if self.edit_callback then + self.edit_callback(self.is_text_edited) + end end function InputText:initKeyboard() @@ -408,11 +419,16 @@ function InputText:getKeyboardDimen() end function InputText:addChars(chars) + 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" then UIManager:scheduleIn(0.3, function() self.enter_callback() end) return end - if not self:isTextEditable(true) then + if self.readonly or not self:isTextEditable(true) then return end self.is_text_edited = true @@ -422,7 +438,7 @@ function InputText:addChars(chars) end function InputText:delChar() - if not self:isTextEditable(true) then + if self.readonly or not self:isTextEditable(true) then return end if self.charpos == 1 then return end @@ -433,7 +449,7 @@ function InputText:delChar() end function InputText:delToStartOfLine() - if not self:isTextEditable(true) then + if self.readonly or not self:isTextEditable(true) then return end if self.charpos == 1 then return end diff --git a/frontend/ui/widget/logindialog.lua b/frontend/ui/widget/logindialog.lua index 0f9d2e633..ee4c25c06 100644 --- a/frontend/ui/widget/logindialog.lua +++ b/frontend/ui/widget/logindialog.lua @@ -52,7 +52,7 @@ function LoginDialog:init() background = Blitbuffer.COLOR_WHITE, VerticalGroup:new{ align = "left", - self.title, + self.title_widget, self.title_bar, -- username input CenterContainer:new{ diff --git a/frontend/ui/widget/multiinputdialog.lua b/frontend/ui/widget/multiinputdialog.lua index 8bd0e310c..c2f3e0307 100644 --- a/frontend/ui/widget/multiinputdialog.lua +++ b/frontend/ui/widget/multiinputdialog.lua @@ -29,7 +29,7 @@ function MultiInputDialog:init() InputDialog.init(self) local VerticalGroupData = VerticalGroup:new{ align = "left", - self.title, + self.title_widget, self.title_bar, } diff --git a/frontend/ui/widget/openwithdialog.lua b/frontend/ui/widget/openwithdialog.lua index 1a687d194..d5fc6e8c2 100644 --- a/frontend/ui/widget/openwithdialog.lua +++ b/frontend/ui/widget/openwithdialog.lua @@ -95,7 +95,7 @@ function OpenWithDialog:init() background = Blitbuffer.COLOR_WHITE, VerticalGroup:new{ align = "left", - self.title, + self.title_widget, self.title_bar, VerticalSpan:new{ width = Size.span.vertical_large*2, diff --git a/frontend/ui/widget/scrolltextwidget.lua b/frontend/ui/widget/scrolltextwidget.lua index 977c4f540..683796899 100644 --- a/frontend/ui/widget/scrolltextwidget.lua +++ b/frontend/ui/widget/scrolltextwidget.lua @@ -121,6 +121,9 @@ function ScrollTextWidget:updateScrollBar(is_partial) UIManager:setDirty(self.dialog, function() return refreshfunc, self.dimen end) + if self.scroll_callback then + self.scroll_callback(low, high) + end end end diff --git a/frontend/util.lua b/frontend/util.lua index f2e15c2df..1a84bf47c 100644 --- a/frontend/util.lua +++ b/frontend/util.lua @@ -471,7 +471,7 @@ end --- Replaces invalid UTF-8 characters with a replacement string. -- --- Based on http://notebook.kulchenko.com/programming/fixing-malformed-utf8-in-lua +-- Based on http://notebook.kulchenko.com/programming/fixing-malformed-utf8-in-lua ---- @string str the string to be checked for invalid characters ---- @string replacement the string to replace invalid characters with ---- @treturn string valid UTF-8 @@ -677,4 +677,18 @@ function util.urlDecode(url) return url end +--- Check lua syntax of string +--- @string text lua code text +--- @treturn string with parsing error, nil if syntax ok +function util.checkLuaSyntax(lua_text) + local lua_code_ok, err = loadstring(lua_text) + if lua_code_ok then + return nil + end + -- Replace: [string "blah blah..."]:3: '=' expected near '123' + -- with: Line 3: '=' expected near '123' + err = err:gsub("%[string \".-%\"]:", "Line ") + return err +end + return util diff --git a/plugins/texteditor.koplugin/main.lua b/plugins/texteditor.koplugin/main.lua new file mode 100644 index 000000000..9e16aa6a8 --- /dev/null +++ b/plugins/texteditor.koplugin/main.lua @@ -0,0 +1,498 @@ +local ConfirmBox = require("ui/widget/confirmbox") +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 Notification = require("ui/widget/notification") +local PathChooser = require("ui/widget/pathchooser") +local Trapper = require("ui/trapper") +local UIManager = require("ui/uimanager") +local WidgetContainer = require("ui/widget/container/widgetcontainer") +local ffiutil = require("ffi/util") +local logger = require("logger") +local util = require("util") +local _ = require("gettext") +local Screen = require("device").screen +local T = ffiutil.template + +local TextEditor = WidgetContainer:new{ + name = "text_editor", + settings_file = DataStorage:getSettingsDir() .. "/text_editor.lua", + settings = nil, -- loaded only when needed + -- how many to display in menu (10x3 pages minus our 3 default menu items): + history_menu_size = 27, + history_keep_size = 60, -- hom many to keep in settings + normal_font = "x_smallinfofont", + monospace_font = "infont", + min_file_size_warn = 200000, -- warn/ask when opening files bigger than this +} + +function TextEditor:init() + self.ui.menu:registerToMainMenu(self) +end + +function TextEditor:loadSettings() + if self.settings then + return + end + self.settings = LuaSettings:open(self.settings_file) + self.history = self.settings:readSetting("history") or {} + self.last_view_pos = self.settings:readSetting("last_view_pos") or {} + self.last_path = self.settings:readSetting("last_path") or ffiutil.realpath(DataStorage:getDataDir()) + self.font_face = self.settings:readSetting("font_face") or self.normal_font + self.font_size = self.settings:readSetting("font_size") or 20 -- x_smallinfofont default size + -- The font settings could be saved in G_reader_setting if we want them + -- to be re-used by default by InputDialog (on certain conditaions, + -- when fullscreen or condensed or add_nav_bar...) + -- + -- Allow users to set their prefered font manually in text_editor.lua + -- (sadly, not via TextEditor itself, as they would be overriden on close) + if self.settings:readSetting("normal_font") then + self.normal_font = self.settings:readSetting("normal_font") + end + if self.settings:readSetting("monospace_font") then + self.monospace_font = self.settings:readSetting("monospace_font") + end +end + +function TextEditor:onFlushSettings() + if self.settings then + self.settings:saveSetting("history", self.history) + self.settings:saveSetting("last_view_pos", self.last_view_pos) + self.settings:saveSetting("last_path", self.last_path) + self.settings:saveSetting("font_face", self.font_face) + self.settings:saveSetting("font_size", self.font_size) + self.settings:flush() + end +end + +function TextEditor:addToMainMenu(menu_items) + menu_items.text_editor = { + text = _("Text editor"), + sub_item_table_func = function() + return self:getSubMenuItems() + end, + } +end + +function TextEditor:getSubMenuItems() + self:loadSettings() + local sub_item_table = { + { + text = _("Text editor settings"), + sub_item_table = { + { + text = _("Set text font size"), + callback = function() + local SpinWidget = require("ui/widget/spinwidget") + local font_size = self.font_size + UIManager:show(SpinWidget:new{ + width = Screen:getWidth() * 0.6, + value = font_size, + value_min = 8, + value_max = 26, + ok_text = _("Set font size"), + title_text = _("Select font size"), + callback = function(spin) + self.font_size = spin.value + end, + }) + end, + }, + { + text = _("Use monospace font"), + checked_func = function() + return self.font_face == self.monospace_font + end, + callback = function() + if self.font_face == self.monospace_font then + self.font_face = self.normal_font + else + self.font_face = self.monospace_font + end + end, + }, + }, + separator = true, + }, + { + text = _("Select a file to open"), + callback = function() + self:chooseFile() + end, + }, + { + text = _("Edit a new empty file"), + callback = function() + self:newFile() + end, + separator = true, + }, + } + for i=1, math.min(#self.history, self.history_menu_size) do + local file_path = self.history[i] + local directory, filename = util.splitFilePathName(file_path) -- luacheck: no unused + table.insert(sub_item_table, { + text = T("%1. %2", i, filename), + callback = function() + self:checkEditFile(file_path, true) + end, + hold_callback = function() + -- Show full path and some info, and propose to remove from history + local text + local attr = lfs.attributes(file_path) + if attr then + local filesize = util.getFormattedSize(attr.size) + local lastmod = os.date("%Y-%m-%d %H:%M", attr.modification) + text = T(_("File path:\n%1\n\nFile size: %2 bytes\nLast modified: %3\n\nRemove this file from text editor history?"), + file_path, filesize, lastmod) + else + text = T(_("File path:\n%1\n\nThis file does not exist anymore.\n\nRemove it from text editor history?"), + file_path) + end + UIManager:show(ConfirmBox:new{ + text = text, + ok_text = _("Yes"), + cancel_text = _("No"), + ok_callback = function() + self:removeFromHistory(file_path) + end, + }) + end, + }) + end + return sub_item_table +end + +function TextEditor:removeFromHistory(file_path) + for i=#self.history, 1, -1 do + if self.history[i] == file_path then + table.remove(self.history, i) + end + end + self.last_view_pos[file_path] = nil +end + +function TextEditor:addToHistory(file_path) + local new_history = {} + table.insert(new_history, file_path) + -- Trim history and cleanup duplicates + local seen = {} + seen[file_path] = true + while #self.history > 0 and #new_history < self.history_keep_size do + local item = table.remove(self.history, 1) + if not seen[item] then + table.insert(new_history, item) + seen[item] = true + end + end + self.history = new_history +end + +function TextEditor:newFile() + self:loadSettings() + UIManager:show(ConfirmBox:new{ + text = _([[To start editing a new file, you will have to: + +- First select a directory +- Then type the new file filename +- And start editing it + +Do you want to proceeed?]]), + ok_text = _("Yes"), + cancel_text = _("No"), + ok_callback = function() + local path_chooser = PathChooser:new{ + select_directory = true, + select_file = false, + height = Screen:getHeight(), + path = self.last_path, + onConfirm = function(dir_path) + local file_input + file_input = InputDialog:new{ + title = _("Enter new file filename"), + input = dir_path == "/" and "/" or dir_path .. "/", + buttons = {{ + { + text = _("Cancel"), + callback = function() + UIManager:close(file_input) + end, + }, + { + text = _("Edit"), + callback = function() + local file_path = file_input:getInputText() + UIManager:close(file_input) + -- Remember last_path + self.last_path = file_path:match("(.*)/") + if self.last_path == "" then self.last_path = "/" end + self:checkEditFile(file_path, false, true) + end, + }, + }}, + } + UIManager:show(file_input) + file_input:onShowKeyboard() + end, + } + UIManager:show(path_chooser) + end, + }) +end + +function TextEditor:chooseFile() + self:loadSettings() + local path_chooser = PathChooser:new{ + select_file = true, + select_directory = false, + detailed_file_info = true, + height = Screen:getHeight(), + path = self.last_path, + onConfirm = function(file_path) + -- Remember last_path only when we select a file from it + self.last_path = file_path:match("(.*)/") + if self.last_path == "" then self.last_path = "/" end + self:checkEditFile(file_path) + end + } + UIManager:show(path_chooser) +end + +function TextEditor:checkEditFile(file_path, from_history, possibly_new_file) + local attr = lfs.attributes(file_path) + if not possibly_new_file and not attr then + UIManager:show(ConfirmBox:new{ + text = T(_("This file does not exist anymore:\n\n%1\n\nDo you want to create it and start editing it?"), file_path), + ok_text = _("Yes"), + cancel_text = _("No"), + ok_callback = function() + -- go again thru there with possibly_new_file=true + self:checkEditFile(file_path, from_history, true) + end, + }) + return + end + if attr then + -- File exists: get its real path with symlink and ../ resolved + file_path = ffiutil.realpath(file_path) + attr = lfs.attributes(file_path) + end + if attr then -- File exists + if attr.mode ~= "file" then + UIManager:show(InfoMessage:new{ + text = T(_("This file is not a regular file:\n\n%1"), file_path) + }) + return + end + -- Check if file is writable ("r+b" checks that, and does not + -- update the last mod timestamp, unlike "wb") + -- No need to warn if readonly, the user will know it when we open + -- without keyboard and the Save button says "Read only". + local readonly = true + local file = io.open(file_path, 'r+b') + if file then + file:close() + readonly = false + end + -- Don't check size if coming from history: user had already confirmed it + if not from_history and attr.size > self.min_file_size_warn then + UIManager:show(ConfirmBox:new{ + text = T(_("This file is %2:\n\n%1\n\nAre you sure you want to open it?\n\nOpening big files may take some time."), + file_path, util.getFriendlySize(attr.size)), + ok_text = _("Yes"), + cancel_text = _("No"), + ok_callback = function() + self:editFile(file_path, readonly) + end, + }) + else + self:editFile(file_path, readonly) + end + else -- File does not exist + -- Try to create it just to check if writting to it later is possible + local file, err = io.open(file_path, "wb") + if file then + -- Clean it, we'll create it again on Save, and allow closing + -- without saving in case the user has changed his mind. + file:close() + os.remove(file_path) + self:editFile(file_path) + else + UIManager:show(InfoMessage:new{ + text = T(_("This file can not be created:\n\n%1\n\nReason: %2"), file_path, err) + }) + return + end + end +end + +function TextEditor:readFileContent(file_path) + local file = io.open(file_path, "rb") + if not file then + -- We checked file existence before, so assume it's + -- because it's a new file + return "" + end + local file_content = file:read("*all") + file:close() + return file_content +end + +function TextEditor:saveFileContent(file_path, content) + local file, err = io.open(file_path, "wb") + if file then + file:write(content) + file:close() + logger.info("TextEditor: saved file", file_path) + return true + end + logger.info("TextEditor: failed saving file", file_path, ":", err) + return false, err +end + +function TextEditor:deleteFile(file_path) + local ok, err = os.remove(file_path) + if ok then + logger.info("TextEditor: deleted file", file_path) + return true + end + logger.info("TextEditor: failed deleting file", file_path, ":", err) + return false, err +end + +function TextEditor:editFile(file_path, readonly) + self:addToHistory(file_path) + local directory, filename = util.splitFilePathName(file_path) -- luacheck: no unused + local filename_without_suffix, filetype = util.splitFileNameSuffix(filename) -- luacheck: no unused + local is_lua = filetype:lower() == "lua" + local input + input = InputDialog:new{ + title = filename, + input = self:readFileContent(file_path), + input_face = Font:getFace(self.font_face, self.font_size), + fullscreen = true, + condensed = true, + allow_newline = true, + cursor_at_end = false, + readonly = readonly, + add_nav_bar = true, + buttons = is_lua and {{ + -- First button on first row, that will be filled with Reset|Save|Close + { + text = _("Check lua"), + callback = function() + local parse_error = util.checkLuaSyntax(input:getInputText()) + if parse_error then + UIManager:show(InfoMessage:new{ + text = T(_("lua syntax check failed:\n\n%1"), parse_error) + }) + else + UIManager:show(Notification:new{ + text = T(_("lua syntax OK")), + timeout = 2, + }) + end + end, + }, + }}, + -- Set/save view and cursor position callback + view_pos_callback = function(top_line_num, charpos) + -- This same callback is called with no argument to get initial position, + -- and with arguments to give back final position when closed. + if top_line_num and charpos then + self.last_view_pos[file_path] = {top_line_num, charpos} + else + local prev_pos = self.last_view_pos[file_path] + if type(prev_pos) == "table" and prev_pos[1] and prev_pos[2] then + return prev_pos[1], prev_pos[2] + end + return nil, nil -- no previous position known + end + end, + -- File restoring callback + reset_callback = function(content) -- Will add a Reset button + return self:readFileContent(file_path), _("Text reset to last saved content") + end, + -- File saving callback + save_callback = function(content, closing) -- Will add Save/Close buttons + if self.readonly then + -- We shouldn't be called if read-only, but just in case + return false, _("File is read only") + end + if content and #content > 0 then + if not is_lua then + local ok, err = self:saveFileContent(file_path, content) + if ok then + return true, _("File saved") + else + return false, T(_("Failed saving file: %1"), err) + end + end + local parse_error = util.checkLuaSyntax(content) + if not parse_error then + local ok, err = self:saveFileContent(file_path, content) + if ok then + return true, _("lua syntax OK, file saved") + else + return false, T(_("Failed saving file: %1"), err) + end + end + local save_anyway = Trapper:confirm(T(_( +[[lua syntax check failed: + +%1 + +KOReader may crash if this is saved. +Do you really want to save to this file? + +%2]]), parse_error, file_path), _("Do not save"), _("Save anyway")) + -- we'll get the safer "Do not save" on tap outside + if save_anyway then + local ok, err = self:saveFileContent(file_path, content) + if ok then + return true, _("File saved") + else + return false, T(_("Failed saving file: %1"), err) + end + else + return false, false -- no need for more InfoMessage + end + else -- If content is empty, propose to delete the file + local delete_file = Trapper:confirm(T(_( +[[Text content is empty. +Do you want to keep this file as empty, or do you prefer to delete it? + +%1]]), file_path), _("Keep empty file"), _("Delete file")) + -- we'll get the safer "Keep empty file" on tap outside + if delete_file then + local ok, err = self:deleteFile(file_path) + if ok then + return true, _("File deleted") + else + return false, T(_("Failed deleting file: %1"), err) + end + else + local ok, err = self:saveFileContent(file_path, content) + if ok then + return true, _("File saved") + else + return false, T(_("Failed saving file: %1"), err) + end + end + end + end, + + } + UIManager:show(input) + input:onShowKeyboard() + -- Note about self.readonly: + -- We might have liked to still show keyboard even if readonly, just + -- to use the arrow keys for line by line scrolling with cursor. + -- But it's easier to just let InputDialog and InputText do their + -- own readonly prevention (and on devices where we run as root, we + -- will hardly ever be readonly). +end + +return TextEditor