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