Files
koreader-mirror/frontend/ui/widget/inputtext.lua
poire-z 0d66ea7555 Text input fixes and enhancements (#4084)
InputText, ScrollTextWidget, TextBoxWidget:
- proper line scrolling when moving cursor or inserting/deleting text
  to behave like most text editors do
- fix cursor navigation, optimize refreshes when moving only the cursor,
  don't recreate the textwidget when moving cursor up/down
- optimize refresh areas, stick to "ui" to avoid a "partial" black
  flash every 6 appended or deleted chars

InputText:
- fix issue when toggling Show password multiple times
- new option: InputText.cursor_at_end (default: true)
- if no InputText.height provided, measure the text widget height
  that we would start with, and use a ScrollTextWidget with that
  fixed height, so widget does not overflow container if we extend
  the text and increase the number of lines
- as we are using "ui" refreshes while text editing, allows refreshing
  the InputText with a diagonal swipe on it (actually, refresh the
  whole screen, which allows refreshing the keyboard too if needed)

ScrollTextWidget:
- properly align scrollbar with its TextBoxWidget

TextBoxWidget:
- some cleanup (added new properties to avoid many method calls), added
  proxy methods for upper widgets to get them
- reordered/renamed/refactored the *CharPos* methods for easier reading
  (sorry for the diff that won't help reviewing, but that was needed)

InputDialog:
- new options:
   allow_newline = false, -- allow entering new lines
   cursor_at_end = true, -- starts with cursor at end of text, ready to append
   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
- find the most adequate text height, when none provided or fullscreen, to
  not overflow screen (and not be stuck with Cancel/Save buttons hidden)
- had to disable the use of a MovableContainer (many issues like becoming
  transparent when a PathChooser comes in front, Hold to paste from
  clipboard, moving the InputDialog under the keyboard and getting stuck...)

GestureRange: fix possible crash (when event processed after widget
destruction ?)

LoginDialog: fix some ui stack increase and possible crash when switching
focus many times.
2018-07-19 08:30:40 +02:00

439 lines
15 KiB
Lua

local Blitbuffer = require("ffi/blitbuffer")
local CheckButton = require("ui/widget/checkbutton")
local Device = require("device")
local FrameContainer = require("ui/widget/container/framecontainer")
local Font = require("ui/font")
local GestureRange = require("ui/gesturerange")
local InputContainer = require("ui/widget/container/inputcontainer")
local ScrollTextWidget = require("ui/widget/scrolltextwidget")
local Size = require("ui/size")
local TextBoxWidget = require("ui/widget/textboxwidget")
local UIManager = require("ui/uimanager")
local VerticalGroup = require("ui/widget/verticalgroup")
local util = require("util")
local _ = require("gettext")
local Screen = Device.screen
local Keyboard
local InputText = InputContainer:new{
text = "",
hint = "demo hint",
input_type = nil, -- "number" or anything else
text_type = nil, -- "password" or anything else
show_password_toggle = true,
cursor_at_end = true, -- starts with cursor at end of text, ready for appending
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
width = nil,
height = nil, -- when nil, will be set to original text height (possibly
-- less if screen would be overflowed) and made scrollable to
-- not overflow if some text is appended and add new lines
face = Font:getFace("smallinfofont"),
padding = Size.padding.default,
margin = Size.margin.default,
bordersize = Size.border.inputtext,
-- for internal use
text_widget = nil, -- Text Widget for cursor movement, possibly a ScrollTextWidget
charlist = nil, -- table of individual chars from input string
charpos = nil, -- position of the cursor, where a new char would be inserted
top_line_num = nil, -- virtual_line_num of the text_widget (index of the displayed top line)
is_password_type = false, -- set to true if original text_type == "password"
}
-- only use PhysicalKeyboard if the device does not have touch screen
if Device.isTouchDevice() or Device.hasDPad() then
Keyboard = require("ui/widget/virtualkeyboard")
if Device.isTouchDevice() then
function InputText:initEventListener()
self.ges_events = {
TapTextBox = {
GestureRange:new{
ges = "tap",
range = self.dimen
}
},
HoldTextBox = {
GestureRange:new{
ges = "hold",
range = self.dimen
}
},
SwipeTextBox = {
GestureRange:new{
ges = "swipe",
range = self.dimen
}
},
-- These are just to stop propagation of the event to
-- parents in case there's a MovableContainer among them
-- Commented for now, as this needs work
-- HoldPanTextBox = {
-- GestureRange:new{ ges = "hold_pan", range = self.dimen }
-- },
-- HoldReleaseTextBox = {
-- GestureRange:new{ ges = "hold_release", range = self.dimen }
-- },
-- PanTextBox = {
-- GestureRange:new{ ges = "pan", range = self.dimen }
-- },
-- PanReleaseTextBox = {
-- GestureRange:new{ ges = "pan_release", range = self.dimen }
-- },
-- TouchTextBox = {
-- GestureRange:new{ ges = "touch", range = self.dimen }
-- },
}
end
-- For MovableContainer to work fully, some of these should
-- do more check before disabling the event or not
-- Commented for now, as this needs work
-- local function _disableEvent() return true end
-- InputText.onHoldPanTextBox = _disableEvent
-- InputText.onHoldReleaseTextBox = _disableEvent
-- InputText.onPanTextBox = _disableEvent
-- InputText.onPanReleaseTextBox = _disableEvent
-- InputText.onTouchTextBox = _disableEvent
function InputText:onTapTextBox(arg, ges)
if self.parent.onSwitchFocus then
self.parent:onSwitchFocus(self)
end
local textwidget_offset = self.margin + self.bordersize + self.padding
local x = ges.pos.x - self._frame_textwidget.dimen.x - textwidget_offset
local y = ges.pos.y - self._frame_textwidget.dimen.y - textwidget_offset
self.text_widget:moveCursorToXY(x, y, true) -- restrict_to_view=true
self.charpos, self.top_line_num = self.text_widget:getCharPos()
return true
end
function InputText:onHoldTextBox(arg, ges)
if self.parent.onSwitchFocus then
self.parent:onSwitchFocus(self)
end
local textwidget_offset = self.margin + self.bordersize + self.padding
local x = ges.pos.x - self._frame_textwidget.dimen.x - textwidget_offset
local y = ges.pos.y - self._frame_textwidget.dimen.y - textwidget_offset
self.text_widget:moveCursorToXY(x, y, true) -- restrict_to_view=true
self.charpos, self.top_line_num = self.text_widget:getCharPos()
if Device:hasClipboard() and Device.input.hasClipboardText() then
self:addChars(Device.input.getClipboardText())
end
return true
end
function InputText:onSwipeTextBox(arg, ges)
-- Allow refreshing the widget (actually, the screen) with the classic
-- Diagonal Swipe, as we're only using the quick "ui" mode while editing
if ges.direction == "northeast" or ges.direction == "northwest"
or ges.direction == "southeast" or ges.direction == "southwest" then
if self.refresh_callback then self.refresh_callback() end
-- Trigger a full-screen HQ flashing refresh so
-- the keyboard can also be fully redrawn
UIManager:setDirty(nil, "full")
end
-- Let it propagate in any case (a long diagonal swipe may also be
-- used for taking a screenshot)
return false
end
end
if Device.hasKeys() then
if not InputText.initEventListener then
function InputText:initEventListener() end
end
function InputText:onFocus()
--Event called by the focusmanager
self.key_events.ShowKeyboard = { {"Press"}, doc = "show keyboard" }
self:focus()
return true
end
function InputText:onUnfocus()
--Event called by the focusmanager
self.key_events = {}
self:unfocus()
return true
end
end
else
Keyboard = require("ui/widget/physicalkeyboard")
function InputText:initEventListener() end
end
function InputText:init()
if self.text_type == "password" then
-- text_type changes from "password" to "text" when we toggle password
self.is_password_type = true
end
self:initTextBox(self.text)
if self.readonly ~= true then
self:initKeyboard()
self:initEventListener()
end
end
-- This will be called when we add or del chars, as we need to recreate
-- the text widget to have the new text splittted into possibly different
-- lines than before
function InputText:initTextBox(text, char_added)
self.text = text
local fgcolor
local show_charlist
local show_text = text
if show_text == "" or show_text == nil then
-- no preset value, use hint text if set
show_text = self.hint
fgcolor = Blitbuffer.COLOR_GREY
self.charlist = {}
self.charpos = 1
else
fgcolor = Blitbuffer.COLOR_BLACK
if self.text_type == "password" then
show_text = self.text:gsub(
"(.-).", function() return "*" end)
if char_added then
show_text = show_text:gsub(
"(.)$", function() return self.text:sub(-1) end)
end
end
self.charlist = util.splitToChars(text)
-- keep previous cursor position if charpos not nil
if self.charpos == nil then
if self.cursor_at_end then
self.charpos = #self.charlist + 1
else
self.charpos = 1
end
end
end
if self.is_password_type and self.show_password_toggle then
self._check_button = self._check_button or CheckButton:new{
text = _("Show password"),
callback = function()
if self.text_type == "text" then
self.text_type = "password"
self._check_button:unCheck()
else
self.text_type = "text"
self._check_button:check()
end
self:setText(self:getText())
end,
padding = self.padding,
margin = self.margin,
bordersize = self.bordersize,
}
self._password_toggle = FrameContainer:new{
bordersize = 0,
padding = self.padding,
margin = self.margin,
self._check_button,
}
else
self._password_toggle = nil
end
show_charlist = util.splitToChars(show_text)
if not self.height then
-- If no height provided, measure the text widget height
-- we would start with, and use a ScrollTextWidget with that
-- height, so widget does not overflow container if we extend
-- the text and increase the number of lines
local text_widget = TextBoxWidget:new{
text = show_text,
charlist = show_charlist,
face = self.face,
width = self.width,
}
self.height = text_widget:getTextHeight()
self.scroll = true
text_widget:free()
end
if self.scroll then
self.text_widget = ScrollTextWidget:new{
text = show_text,
charlist = show_charlist,
charpos = self.charpos,
top_line_num = self.top_line_num,
editable = self.focused,
face = self.face,
fgcolor = fgcolor,
width = self.width,
height = self.height,
dialog = self.parent,
}
else
self.text_widget = TextBoxWidget:new{
text = show_text,
charlist = show_charlist,
charpos = self.charpos,
top_line_num = self.top_line_num,
editable = self.focused,
face = self.face,
fgcolor = fgcolor,
width = self.width,
height = self.height,
dialog = self.parent,
}
end
-- Get back possibly modified charpos and virtual_line_num
self.charpos, self.top_line_num = self.text_widget:getCharPos()
self._frame_textwidget = FrameContainer:new{
bordersize = self.bordersize,
padding = self.padding,
margin = self.margin,
color = self.focused and Blitbuffer.COLOR_BLACK or Blitbuffer.COLOR_GREY,
self.text_widget,
}
self._verticalgroup = VerticalGroup:new{
align = "left",
self._frame_textwidget,
self._password_toggle,
}
self._frame = FrameContainer:new{
bordersize = 0,
margin = 0,
padding = 0,
self._verticalgroup,
}
self[1] = self._frame
self.dimen = self._frame:getSize()
-- FIXME: self.parent is not always in the widget stack (BookStatusWidget)
UIManager:setDirty(self.parent, function()
return "ui", self.dimen
end)
end
function InputText:initKeyboard()
local keyboard_layout = 2
if self.input_type == "number" then
keyboard_layout = 4
end
self.key_events = nil
self.keyboard = Keyboard:new{
keyboard_layout = keyboard_layout,
inputbox = self,
width = Screen:getWidth(),
}
end
function InputText:unfocus()
self.focused = false
self.text_widget:unfocus()
self._frame_textwidget.color = Blitbuffer.COLOR_GREY
end
function InputText:focus()
self.focused = true
self.text_widget:focus()
self._frame_textwidget.color = Blitbuffer.COLOR_BLACK
end
function InputText:onShowKeyboard()
UIManager:show(self.keyboard)
return true
end
function InputText:onCloseKeyboard()
UIManager:close(self.keyboard)
end
function InputText:getTextHeight()
return self.text_widget:getTextHeight()
end
function InputText:getLineHeight()
return self.text_widget:getLineHeight()
end
function InputText:getKeyboardDimen()
return self.keyboard.dimen
end
function InputText:addChars(chars)
if self.enter_callback and chars == "\n" then
UIManager:scheduleIn(0.3, function() self.enter_callback() end)
return
end
table.insert(self.charlist, self.charpos, chars)
self.charpos = self.charpos + #util.splitToChars(chars)
self:initTextBox(table.concat(self.charlist), true)
end
function InputText:delChar()
if self.charpos == 1 then return end
self.charpos = self.charpos - 1
table.remove(self.charlist, self.charpos)
self:initTextBox(table.concat(self.charlist))
end
-- For the following cursor/scroll methods, the text_widget deals
-- itself with setDirty'ing the appropriate regions
function InputText:leftChar()
if self.charpos == 1 then return end
self.text_widget:moveCursorLeft()
self.charpos, self.top_line_num = self.text_widget:getCharPos()
end
function InputText:rightChar()
if self.charpos > #self.charlist then return end
self.text_widget:moveCursorRight()
self.charpos, self.top_line_num = self.text_widget:getCharPos()
end
function InputText:upLine()
self.text_widget:moveCursorUp()
self.charpos, self.top_line_num = self.text_widget:getCharPos()
end
function InputText:downLine()
self.text_widget:moveCursorDown()
self.charpos, self.top_line_num = self.text_widget:getCharPos()
end
function InputText:scrollDown()
self.text_widget:scrollDown()
self.charpos, self.top_line_num = self.text_widget:getCharPos()
end
function InputText:scrollUp()
self.text_widget:scrollUp()
self.charpos, self.top_line_num = self.text_widget:getCharPos()
end
function InputText:scrollToTop()
self.text_widget:scrollToTop()
self.charpos, self.top_line_num = self.text_widget:getCharPos()
end
function InputText:scrollToBottom()
self.text_widget:scrollToBottom()
self.charpos, self.top_line_num = self.text_widget:getCharPos()
end
function InputText:clear()
self.charpos = nil
self.top_line_num = 1
self:initTextBox("")
end
function InputText:getText()
return self.text
end
function InputText:setText(text)
-- Keep previous charpos and top_line_num
self:initTextBox(text)
end
return InputText