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.
pull/4142/head
poire-z 6 years ago committed by GitHub
parent 1d18b01cf7
commit 6e35e683dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -51,6 +51,7 @@ local order = {
"read_timer",
"news_downloader",
"send2ebook",
"text_editor",
"----------------------------",
"more_plugins",
"----------------------------",

@ -72,6 +72,7 @@ local order = {
"zsync",
"news_downloader",
"send2ebook",
"text_editor",
"----------------------------",
"more_plugins",
},

@ -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

@ -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 <em>must</em> 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

@ -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

@ -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{

@ -29,7 +29,7 @@ function MultiInputDialog:init()
InputDialog.init(self)
local VerticalGroupData = VerticalGroup:new{
align = "left",
self.title,
self.title_widget,
self.title_bar,
}

@ -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,

@ -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

@ -471,7 +471,7 @@ end
--- Replaces invalid UTF-8 characters with a replacement string.
--
-- Based on <a href="http://notebook.kulchenko.com/programming/fixing-malformed-utf8-in-lua">http://notebook.kulchenko.com/programming/fixing-malformed-utf8-in-lua</a>
-- 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

@ -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
Loading…
Cancel
Save