mirror of
https://github.com/koreader/koreader.git
synced 2025-08-10 00:52:38 +00:00
* FocusManager: Fix `focus_flags` check in `moveFocusTo` (0 is truthy in Lua, can't do AND checks like in C ;).) * FileManager+FileChooser: Pass our custom title bar directly to FileChooser (which also means we can now use FC's FocusManager layout directly). * FileChooser/Menu: Get rid of the weird `outer_title_bar` hack, and simply take a `custom_title_bar` pointer to an actual TitleBar instance instead. * FileManager/Menu/ListMenu/CoverMenu: Fix content height computations in `_recalculateDimen` (all the non-FM cases were including an old and now unused padding value, `self.header_padding`, leading to more blank space at the bottom than necessary, and, worse, leading to different item heights between FM views, possibly leading to unnecessary thumbnail scaling !) * ButtonDialog: Proper focus management when the ButtonTable is wrapped in a ScrollableContainer. * ConfigDialog: Implement a stupid workaround for a weird FocusManager issue when going back from `[⋮]` buttons. * ConfigDialog: Don't move the visual focus in `update` (i.e., we use `NOT_FOCUS` now that it works as intended). * DictQuickLookup: Ensures the `Menu` key bind does the exact same thing as the hamburger icon. * DictQuickLookup: Ensure we refocus after having mangled the FocusManager layout (prevents an old focus highlight from lingering on the wrong button). * FileChooser: Stop flagging it as no_title, because it is *never* without a title. (This behavior was a remnant of the previous FM-specific title bar hacks, which are no longer a thing). * FileChooser: Stop calling `mergeTitleBarIntoLayout` twice in `updateItems`. We already call Menu's, which handles it. (Prevents the title bar from being added twice to the FocusManager layout). * FocusManager: Relax the `Unfocus` checks in `moveFocusTo` to ensure we *always* unfocus something (if unfocusing was requested), even if we have to blast the whole widget tree to do so. This ensures callers that mangle self.layout can expect things to work after calling it regardless of how borked the current focus is. * FocusManager: Allow passing `focus_flags` to `refocusWidget`, so that it can be forwarded to the internal `moveFocusTo` call. * FocusManager: The above also allows us to enforce a default that ensures we do *not* send a Focus event on Touch devices, even if they have the hasDPad devcap. This essentially restores the previous/current behavior of not showing the visual feedback from such focus "events" sent programmatically, given the `focus_flags` check fix at the root of this PR ;). * InputDialog: Fix numerous issues relating to double/ghost instances of both InputText and VirtualKeyboard, ensuring we only ever have a single InputText & VK instance live. * InputDialog: Make sure every way we have of hiding the VK play nice together, especially when the `toggleKeyboard` button (shown w/ `add_nav_bar`) is at play. And doubly so when we're `fullscreen`, as hiding the VK implies resizing the widget. * InputText: Make sure we're flagged as in-focus when tapping inside the text field. * InputText: Make sure we don't attempt to show an already-visible VK in the custom `hasDPad` `onFocus` handler. * Menu: Get rid of an old and no longer used (nor meaningful) hack in `onFocus` about the initial/programmatically-sent Focus event. * Menu: Get rid of the unused `header_padding` field mentioned earlier in the FM/FC fixes. * Menu: Use `FOCUS_ONLY_ON_NT` in the explicit `moveFocusTo` call in `updatePageInfo`, so as to keep the current behavior of not showing the visual feedback of this focus on Touch devices. * Menu: Make sure *all* the `moveFocusTo` calls are gated behind the `hasDPad` devcap (previously, that was only the case for `updatePageInfo`, but not `mergeTitleBarIntoLayout` (which is called by `updateItems`). * MultiInputDialog: Actively get rid of the InputText & VK instances from the base class's constructor that we do not use. * MultiInputDialog: Ensure the FocusManager layout is *slightly* less broken (password fields can still be a bit weird, though). * TextViewer: Get rid of the unfocus -> layout mangling -> refocus hack now that `refocusWidget` handles this case sanely. * VirtualKeyboard: Notify our parent InputDialog when we get closed, so it can act accordingly (e.g., resize itself when `fullscreen`). * ScrollableContainer: Implement the necessary machinery for focus handling inside ButtonDialog (specifically, when scrolling via PgUp/PgDwn). * TextEditor: Given the above fixes, the plugin is no longer disabled on non-touch devices. * ReaderBookMark: Make sure we request a full refresh when closing the "Edit note" dialog, as CRe highlights may extend past its dimensions, and if it's closed separately from VK, the refresh would have been limited to its own dimensions, leaving a neat InputDialog-sized hole in the highlights ;).
1060 lines
39 KiB
Lua
1060 lines
39 KiB
Lua
local Blitbuffer = require("ffi/blitbuffer")
|
||
local CheckButton = require("ui/widget/checkbutton")
|
||
local Device = require("device")
|
||
local FocusManager = require("ui/widget/focusmanager")
|
||
local FrameContainer = require("ui/widget/container/framecontainer")
|
||
local Font = require("ui/font")
|
||
local Geom = require("ui/geometry")
|
||
local GestureRange = require("ui/gesturerange")
|
||
local InputContainer = require("ui/widget/container/inputcontainer")
|
||
local Notification = require("ui/widget/notification")
|
||
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 dbg = require("dbg")
|
||
local util = require("util")
|
||
local _ = require("gettext")
|
||
local Screen = Device.screen
|
||
|
||
local Keyboard -- Conditional instantiation
|
||
local FocusManagerInstance -- Delayed instantiation
|
||
|
||
local InputText = InputContainer:extend{
|
||
text = "",
|
||
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
|
||
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 (c.f., ScrollTextWidget)
|
||
scroll_by_pan = false, -- allow scrolling by lines with Pan (needs scroll=true)
|
||
|
||
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,
|
||
|
||
-- See TextBoxWidget for details about these options
|
||
alignment = "left",
|
||
justified = false,
|
||
lang = nil,
|
||
para_direction_rtl = nil,
|
||
auto_para_direction = false,
|
||
alignment_strict = false,
|
||
|
||
readonly = nil, -- will not support a Keyboard widget if true
|
||
|
||
-- for internal use
|
||
keyboard = nil, -- Keyboard widget (either VirtualKeyboard or PhysicalKeyboard)
|
||
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"
|
||
is_text_editable = true, -- whether text is utf8 reversible and editing won't mess content
|
||
is_text_edited = false, -- whether text has been updated
|
||
for_measurement_only = nil, -- When the widget is a one-off used to compute text height
|
||
do_select = false, -- to start text selection
|
||
selection_start_pos = nil, -- selection start position
|
||
}
|
||
|
||
-- These may be (internally) overloaded as needed, depending on Device capabilities.
|
||
function InputText:initEventListener() end
|
||
function InputText:onFocus() end
|
||
function InputText:onUnfocus() end
|
||
|
||
-- Resync our position state with our text widget's actual state
|
||
function InputText:resyncPos()
|
||
self.charpos, self.top_line_num = self.text_widget:getCharPos()
|
||
end
|
||
|
||
local function initTouchEvents()
|
||
if Device:isTouchDevice() then
|
||
function InputText:initEventListener()
|
||
self.ges_events = {
|
||
TapTextBox = {
|
||
GestureRange:new{
|
||
ges = "tap",
|
||
range = function() return self.dimen end
|
||
}
|
||
},
|
||
HoldTextBox = {
|
||
GestureRange:new{
|
||
ges = "hold",
|
||
range = function() return self.dimen end
|
||
}
|
||
},
|
||
HoldReleaseTextBox = {
|
||
GestureRange:new{
|
||
ges = "hold_release",
|
||
range = function() return self.dimen end
|
||
}
|
||
},
|
||
SwipeTextBox = {
|
||
GestureRange:new{
|
||
ges = "swipe",
|
||
range = function() return self.dimen end
|
||
}
|
||
},
|
||
-- 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 }
|
||
-- },
|
||
-- 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)
|
||
else
|
||
if self.keyboard then
|
||
self.keyboard:showKeyboard()
|
||
end
|
||
-- Make sure we're flagged as in focus again
|
||
self:focus()
|
||
end
|
||
if self._frame_textwidget.dimen ~= nil -- zh keyboard with candidates shown here has _frame_textwidget.dimen = nil
|
||
and #self.charlist > 0 then -- do not move cursor within a hint
|
||
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:resyncPos()
|
||
end
|
||
return true
|
||
end
|
||
|
||
function InputText:onHoldTextBox(arg, ges)
|
||
if self.parent.onSwitchFocus then
|
||
self.parent:onSwitchFocus(self)
|
||
end
|
||
-- clipboard dialog
|
||
self._hold_handled = nil
|
||
if Device:hasClipboard() then
|
||
if self.do_select then -- select mode on
|
||
if self.selection_start_pos then -- select end
|
||
local selection_end_pos = self.charpos - 1
|
||
if self.selection_start_pos > selection_end_pos then
|
||
self.selection_start_pos, selection_end_pos = selection_end_pos + 1, self.selection_start_pos - 1
|
||
end
|
||
local txt = table.concat(self.charlist, "", self.selection_start_pos, selection_end_pos)
|
||
Device.input.setClipboardText(txt)
|
||
UIManager:show(Notification:new{
|
||
text = _("Selection copied to clipboard."),
|
||
})
|
||
self.selection_start_pos = nil
|
||
self.do_select = false
|
||
self:initTextBox()
|
||
else -- select start
|
||
self.selection_start_pos = self.charpos
|
||
UIManager:show(Notification:new{
|
||
text = _("Set cursor to end of selection, then long-press in text box."),
|
||
})
|
||
end
|
||
self._hold_handled = true
|
||
return true
|
||
end
|
||
local clipboard_value = Device.input.getClipboardText()
|
||
local is_clipboard_empty = clipboard_value == ""
|
||
local clipboard_dialog
|
||
clipboard_dialog = require("ui/widget/textviewer"):new{
|
||
title = _("Clipboard"),
|
||
show_menu = false,
|
||
text = is_clipboard_empty and _("(empty)") or clipboard_value,
|
||
fgcolor = is_clipboard_empty and Blitbuffer.COLOR_DARK_GRAY or Blitbuffer.COLOR_BLACK,
|
||
width = math.floor(math.min(Screen:getWidth(), Screen:getHeight()) * 0.8),
|
||
height = math.floor(math.max(Screen:getWidth(), Screen:getHeight()) * 0.4),
|
||
justified = false,
|
||
modal = true,
|
||
stop_events_propagation = true,
|
||
buttons_table = {
|
||
{
|
||
{
|
||
text = _("Copy all"),
|
||
callback = function()
|
||
UIManager:close(clipboard_dialog)
|
||
Device.input.setClipboardText(table.concat(self.charlist))
|
||
UIManager:show(Notification:new{
|
||
text = _("All text copied to clipboard."),
|
||
})
|
||
end,
|
||
},
|
||
{
|
||
text = _("Copy line"),
|
||
callback = function()
|
||
UIManager:close(clipboard_dialog)
|
||
local txt = table.concat(self.charlist, "", self:getStringPos())
|
||
Device.input.setClipboardText(txt)
|
||
UIManager:show(Notification:new{
|
||
text = _("Line copied to clipboard."),
|
||
})
|
||
end,
|
||
},
|
||
{
|
||
text = _("Copy word"),
|
||
callback = function()
|
||
UIManager:close(clipboard_dialog)
|
||
local txt = table.concat(self.charlist, "", self:getStringPos(true))
|
||
Device.input.setClipboardText(txt)
|
||
UIManager:show(Notification:new{
|
||
text = _("Word copied to clipboard."),
|
||
})
|
||
end,
|
||
},
|
||
},
|
||
{
|
||
{
|
||
text = _("Delete all"),
|
||
enabled = #self.charlist > 0,
|
||
callback = function()
|
||
UIManager:close(clipboard_dialog)
|
||
self:delAll()
|
||
end,
|
||
},
|
||
{
|
||
text = _("Select"),
|
||
callback = function()
|
||
UIManager:close(clipboard_dialog)
|
||
UIManager:show(Notification:new{
|
||
text = _("Set cursor to start of selection, then long-press in text box."),
|
||
})
|
||
self.do_select = true
|
||
self:initTextBox()
|
||
end,
|
||
},
|
||
{
|
||
text = _("Paste"),
|
||
enabled = not is_clipboard_empty,
|
||
callback = function()
|
||
UIManager:close(clipboard_dialog)
|
||
self:addChars(clipboard_value)
|
||
end,
|
||
},
|
||
},
|
||
},
|
||
}
|
||
UIManager:show(clipboard_dialog)
|
||
end
|
||
self._hold_handled = true
|
||
return true
|
||
end
|
||
|
||
function InputText:onHoldReleaseTextBox(arg, ges)
|
||
if self._hold_handled then
|
||
self._hold_handled = nil
|
||
return true
|
||
end
|
||
return false
|
||
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
|
||
end
|
||
|
||
local function initDPadEvents()
|
||
if Device:hasDPad() then
|
||
function InputText:onFocus()
|
||
-- Event sent by focusmanager
|
||
if self.parent.onSwitchFocus then
|
||
self.parent:onSwitchFocus(self)
|
||
elseif (Device:hasKeyboard() or Device:hasScreenKB()) and G_reader_settings:isFalse("virtual_keyboard_enabled") then
|
||
do end -- luacheck: ignore 541
|
||
else
|
||
if not self:isKeyboardVisible() then
|
||
self:onShowKeyboard()
|
||
end
|
||
end
|
||
self:focus()
|
||
return true
|
||
end
|
||
|
||
function InputText:onUnfocus()
|
||
-- Event called by the focusmanager
|
||
self:unfocus()
|
||
return true
|
||
end
|
||
end
|
||
end
|
||
|
||
-- only use PhysicalKeyboard if the device does not support touch input
|
||
function InputText.initInputEvents()
|
||
FocusManagerInstance = nil
|
||
|
||
if Device:isTouchDevice() or Device:hasDPad() then
|
||
Keyboard = require("ui/widget/virtualkeyboard")
|
||
initTouchEvents()
|
||
initDPadEvents()
|
||
else
|
||
Keyboard = require("ui/widget/physicalkeyboard")
|
||
end
|
||
end
|
||
|
||
InputText.initInputEvents()
|
||
|
||
function InputText:checkTextEditability()
|
||
-- The split of the 'text' string to a table of utf8 chars may not be
|
||
-- reversible to the same string, if 'text' comes from a binary file
|
||
-- (it looks like it does not necessarily need to be proper UTF8 to
|
||
-- be reversible, some text with latin1 chars is reversible).
|
||
-- As checking that may be costly, we do that only in init() and setText().
|
||
-- When not reversible, we prevent adding and deleting chars to not
|
||
-- corrupt the original self.text.
|
||
self.is_text_editable = true
|
||
if self.text then
|
||
-- We check that the text obtained from the UTF8 split done
|
||
-- in :initTextBox(), when concatenated back to a string, matches
|
||
-- the original text. (If this turns out too expensive, we could
|
||
-- just compare their lengths)
|
||
self.is_text_editable = table.concat(self.charlist) == self.text
|
||
end
|
||
end
|
||
|
||
function InputText:isTextEditable(show_warning)
|
||
if show_warning and not self.is_text_editable then
|
||
UIManager:show(Notification:new{
|
||
text = _("Text may be binary content, and is not editable"),
|
||
})
|
||
end
|
||
return self.is_text_editable
|
||
end
|
||
|
||
function InputText:isTextEdited()
|
||
return self.is_text_edited
|
||
end
|
||
|
||
function InputText:init()
|
||
if Device:isTouchDevice() then
|
||
if self.text_type == "password" then
|
||
-- text_type changes from "password" to "text" when we toggle password
|
||
self.is_password_type = true
|
||
end
|
||
else
|
||
-- focus move does not work with textbox and show password checkbox
|
||
-- force show password for non-touch device
|
||
self.text_type = "text"
|
||
self.is_password_type = false
|
||
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" then
|
||
if type(self.text) == "number" then
|
||
-- checkTextEditability() fails if self.text stays not a string
|
||
self.text = tostring(self.text)
|
||
end
|
||
if type(self.hint) == "number" then
|
||
self.hint = tostring(self.hint)
|
||
end
|
||
end
|
||
self.charlist = util.splitToChars(self.text)
|
||
self:initTextBox(self.text)
|
||
self:checkTextEditability()
|
||
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)
|
||
if self.text_widget then
|
||
self.text_widget:free(true)
|
||
end
|
||
|
||
-- 'text' is passed in init() and setText() only, to check editability;
|
||
-- other methods modify and provide self.charlist
|
||
self.text = text or table.concat(self.charlist)
|
||
local show_charlist, show_text, fgcolor
|
||
if self.text == "" then
|
||
-- no preset value, use hint text if set
|
||
show_text = self.hint
|
||
fgcolor = Blitbuffer.COLOR_DARK_GRAY
|
||
self.charlist = {}
|
||
self.charpos = 1
|
||
else
|
||
fgcolor = Blitbuffer.COLOR_BLACK
|
||
if self.text_type == "password" then
|
||
show_charlist = {}
|
||
for i = 1, #self.charlist do
|
||
if char_added and i == self.charpos - 1 then -- show last entered char
|
||
show_charlist[i] = self.charlist[i]
|
||
else
|
||
show_charlist[i] = "*"
|
||
end
|
||
end
|
||
show_text = table.concat(show_charlist)
|
||
else
|
||
show_charlist = self.charlist
|
||
show_text = self.text
|
||
end
|
||
-- 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"),
|
||
parent = self,
|
||
width = self.width,
|
||
callback = function()
|
||
self.text_type = self._check_button.checked and "text" or "password"
|
||
self:setText(self:getText(), true)
|
||
end,
|
||
}
|
||
self._password_toggle = FrameContainer:new{
|
||
bordersize = 0,
|
||
padding = self.padding,
|
||
padding_top = 0,
|
||
padding_bottom = 0,
|
||
margin = self.margin,
|
||
self._check_button,
|
||
}
|
||
else
|
||
self._password_toggle = nil
|
||
end
|
||
|
||
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_width = self.width
|
||
if text_width then
|
||
-- Account for the scrollbar that will be used
|
||
local scroll_bar_width = ScrollTextWidget.scroll_bar_width + ScrollTextWidget.text_scroll_span
|
||
text_width = text_width - scroll_bar_width
|
||
end
|
||
local text_widget = TextBoxWidget:new{
|
||
text = show_text,
|
||
charlist = show_charlist,
|
||
face = self.face,
|
||
width = text_width,
|
||
lang = self.lang, -- these might influence height
|
||
para_direction_rtl = self.para_direction_rtl,
|
||
auto_para_direction = self.auto_para_direction,
|
||
for_measurement_only = true, -- flag it as a dummy, so it won't trigger any bogus repaint/refresh...
|
||
}
|
||
self.height = text_widget:getTextHeight()
|
||
self.scroll = true
|
||
text_widget:free(true)
|
||
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,
|
||
select_mode = self.do_select,
|
||
face = self.face,
|
||
fgcolor = fgcolor,
|
||
alignment = self.alignment,
|
||
justified = self.justified,
|
||
lang = self.lang,
|
||
para_direction_rtl = self.para_direction_rtl,
|
||
auto_para_direction = self.auto_para_direction,
|
||
alignment_strict = self.alignment_strict,
|
||
width = self.width,
|
||
height = self.height,
|
||
dialog = self.parent,
|
||
scroll_callback = self.scroll_callback,
|
||
scroll_by_pan = self.scroll_by_pan,
|
||
for_measurement_only = self.for_measurement_only,
|
||
}
|
||
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,
|
||
select_mode = self.do_select,
|
||
face = self.face,
|
||
fgcolor = fgcolor,
|
||
alignment = self.alignment,
|
||
justified = self.justified,
|
||
lang = self.lang,
|
||
para_direction_rtl = self.para_direction_rtl,
|
||
auto_para_direction = self.auto_para_direction,
|
||
alignment_strict = self.alignment_strict,
|
||
width = self.width,
|
||
height = self.height,
|
||
dialog = self.parent,
|
||
for_measurement_only = self.for_measurement_only,
|
||
}
|
||
end
|
||
-- Get back possibly modified charpos and virtual_line_num
|
||
self:resyncPos()
|
||
|
||
self._frame_textwidget = FrameContainer:new{
|
||
bordersize = self.bordersize,
|
||
padding = self.padding,
|
||
margin = self.margin,
|
||
color = self.focused and Blitbuffer.COLOR_BLACK or Blitbuffer.COLOR_DARK_GRAY,
|
||
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)
|
||
-- Don't even try to refresh dummy widgets used for text height computations...
|
||
if not self.for_measurement_only then
|
||
UIManager:setDirty(self.parent, function()
|
||
return "ui", self.dimen
|
||
end)
|
||
end
|
||
if self.edit_callback then
|
||
self.edit_callback(self.is_text_edited)
|
||
end
|
||
end
|
||
|
||
function InputText:initKeyboard()
|
||
self.key_events = {}
|
||
self.keyboard = Keyboard:new{
|
||
keyboard_layer = self.input_type == "number" and 4 or 2,
|
||
inputbox = self,
|
||
}
|
||
end
|
||
|
||
function InputText:unfocus()
|
||
self.focused = false
|
||
self.text_widget:unfocus()
|
||
self._frame_textwidget.color = Blitbuffer.COLOR_DARK_GRAY
|
||
end
|
||
|
||
function InputText:focus()
|
||
self.focused = true
|
||
self.text_widget:focus()
|
||
self._frame_textwidget.color = Blitbuffer.COLOR_BLACK
|
||
end
|
||
|
||
-- NOTE: This key_map can be used for keyboards without numeric keys, such as on Kindles with keyboards. It is loosely 'inspired' by the symbol layer on the virtual keyboard but,
|
||
-- we have taken the liberty of making some adjustments since:
|
||
-- * K3 does not have numeric keys (top row) and,
|
||
-- * we want to prioritise the most-likely-used characters for "style tweaks" and note taking
|
||
-- (in English, sorry everybody else, there are just not enough keys)
|
||
local sym_key_map = {
|
||
["Q"] = "!", ["W"] = "?", ["E"] = "-", ["R"] = "_", ["T"] = "%", ["Y"] = "=", ["U"] = "7", ["I"] = "8", ["O"] = "9", ["P"] = "0",
|
||
["A"] = "<", ["S"] = ">", ["D"] = "(", ["F"] = ")", ["G"] = "#", ["H"] = "'", ["J"] = "4", ["K"] = "5", ["L"] = "6",
|
||
["Z"] = "{", ["X"] = "}", ["C"] = "[", ["V"] = "]", ["B"] = "1", ["N"] = "2", ["M"] = "3", ["."] = ":", ["AA"] = ";",
|
||
}
|
||
|
||
-- Handle real keypresses from a physical keyboard, even if the virtual keyboard
|
||
-- is shown. Mostly likely to be in the emulator, but could be Android + BT
|
||
-- keyboard, or a "coder's keyboard" Android input method.
|
||
function InputText:onKeyPress(key)
|
||
-- only handle key on focused status, otherwise there are more than one InputText
|
||
-- the first one always handle key pressed
|
||
if not self.focused then
|
||
return false
|
||
end
|
||
local handled = true
|
||
|
||
if not key["Ctrl"] and not key["Shift"] and not key["Alt"] and not key["ScreenKB"] then
|
||
if key["Backspace"] then
|
||
self:delChar()
|
||
elseif key["Del"] then
|
||
-- Kindles with physical keyboards only have a "Del" key (no "Backspace").
|
||
if Device:hasSymKey() then
|
||
self:delChar()
|
||
else
|
||
self:delNextChar()
|
||
end
|
||
elseif key["Left"] then
|
||
self:leftChar()
|
||
elseif key["Right"] then
|
||
self:rightChar()
|
||
-- NOTE: When we are not showing the virtual keyboard, let focusmanger handle up/down keys, as they are used to directly move around the widget
|
||
-- seemlessly in and out of text fields and onto virtual buttons like `[cancel] [search dict]`, no need to unfocus first.
|
||
elseif key["Up"] and G_reader_settings:nilOrTrue("virtual_keyboard_enabled") then
|
||
self:upLine()
|
||
elseif key["Down"] and G_reader_settings:nilOrTrue("virtual_keyboard_enabled") then
|
||
self:downLine()
|
||
elseif key["End"] then
|
||
self:goToEnd()
|
||
elseif key["Home"] then
|
||
self:goToHome()
|
||
elseif key["Press"] then
|
||
self:addChars("\n")
|
||
elseif key["Tab"] then
|
||
self:addChars(" ")
|
||
-- as stated before, we also don't need to unfocus when there is no keyboard, one less key press to exit widgets, yay!
|
||
elseif key["Back"] and G_reader_settings:nilOrTrue("virtual_keyboard_enabled") then
|
||
if self.focused then
|
||
self:unfocus()
|
||
end
|
||
else
|
||
handled = false
|
||
end
|
||
elseif key["Ctrl"] and not key["Shift"] and not key["Alt"] then
|
||
if key["U"] then
|
||
self:delToStartOfLine()
|
||
elseif key["H"] then
|
||
self:delChar()
|
||
else
|
||
handled = false
|
||
end
|
||
else
|
||
handled = false
|
||
end
|
||
-- This primarily targets Kindle. When a virtual keyboard is shown on screen, mod+dpad allows controlling the cursor, as dpad alone
|
||
-- (see previous ‘if’) is now occupied handling the virtual keyboard.
|
||
if not handled and (key["ScreenKB"] or key["Shift"]) then
|
||
handled = true
|
||
if key["Back"] and Device:hasScreenKB() then
|
||
self:delChar()
|
||
elseif key["Back"] and Device:hasSymKey() then
|
||
self:delToStartOfLine()
|
||
elseif key["Del"] and Device:hasSymKey() then
|
||
self:delWord()
|
||
elseif key["Left"] then
|
||
self:leftChar()
|
||
elseif key["Right"] then
|
||
self:rightChar()
|
||
elseif key["Up"] then
|
||
self:upLine()
|
||
elseif key["Down"] then
|
||
self:downLine()
|
||
elseif key["Home"] then
|
||
if self.keyboard:isVisible() then
|
||
self:onCloseKeyboard()
|
||
else
|
||
self:onShowKeyboard()
|
||
end
|
||
elseif key["."] and Device:hasSymKey() then
|
||
-- Kindle does not have a dedicated button for commas
|
||
self:addChars(",")
|
||
else
|
||
handled = false
|
||
end
|
||
end
|
||
if not handled and Device:hasSymKey() then
|
||
handled = true
|
||
local symkey = sym_key_map[key.key]
|
||
-- Do not match Shift + Sym + 'Alphabet keys'
|
||
if symkey and key.modifiers["Sym"] and not key.modifiers["Shift"] then
|
||
self:addChars(symkey)
|
||
else
|
||
handled = false
|
||
end
|
||
end
|
||
if not handled and Device:hasDPad() then
|
||
-- FocusManager may turn on alternative key maps.
|
||
-- These key map maybe single text keys.
|
||
-- It will cause unexpected focus move instead of enter text to InputText
|
||
if not FocusManagerInstance then
|
||
FocusManagerInstance = FocusManager:new{}
|
||
end
|
||
local is_alternative_key = FocusManagerInstance:isAlternativeKey(key)
|
||
if not is_alternative_key and Device:isSDL() then
|
||
-- SDL already insert char via TextInput event
|
||
-- Stop event propagate to FocusManager
|
||
return true
|
||
end
|
||
-- if it is single text char, insert it
|
||
local key_code = key.key -- is in upper case
|
||
if not Device.isSDL() and #key_code == 1 then
|
||
if key["Shift"] and key["Alt"] and key["G"] then
|
||
-- Allow the screenshot keyboard-shortcut to work when focus is on InputText
|
||
return false
|
||
end
|
||
if not key["Shift"] then
|
||
key_code = string.lower(key_code)
|
||
end
|
||
for modifier, flag in pairs(key.modifiers) do
|
||
if modifier ~= "Shift" and flag then -- Other modifier: not a single char insert
|
||
return true
|
||
end
|
||
end
|
||
self:addChars(key_code)
|
||
return true
|
||
end
|
||
if is_alternative_key then
|
||
return true -- Stop event propagate to FocusManager to void focus move
|
||
end
|
||
end
|
||
return handled
|
||
end
|
||
|
||
-- Handle text coming directly as text from the Device layer (eg. soft keyboard
|
||
-- or via SDL's keyboard mapping).
|
||
function InputText:onTextInput(text)
|
||
-- for more than one InputText, let the focused one add chars
|
||
if self.focused then
|
||
self:addChars(text)
|
||
return true
|
||
end
|
||
return false
|
||
end
|
||
dbg:guard(InputText, "onTextInput",
|
||
function(self, text)
|
||
assert(type(text) == "string",
|
||
"Wrong text type (expected string)")
|
||
end)
|
||
|
||
function InputText:onShowKeyboard(ignore_first_hold_release)
|
||
if self.keyboard then
|
||
self.keyboard:showKeyboard(ignore_first_hold_release)
|
||
end
|
||
return true
|
||
end
|
||
|
||
function InputText:onCloseKeyboard()
|
||
if self.keyboard then
|
||
self.keyboard:hideKeyboard()
|
||
end
|
||
end
|
||
|
||
function InputText:isKeyboardVisible()
|
||
if self.keyboard then
|
||
return self.keyboard:isVisible()
|
||
end
|
||
end
|
||
|
||
function InputText:lockKeyboard(toggle)
|
||
if self.keyboard then
|
||
return self.keyboard:lockVisibility(toggle)
|
||
end
|
||
end
|
||
|
||
function InputText:onCloseWidget()
|
||
if self.keyboard then
|
||
self.keyboard:free()
|
||
end
|
||
self:free()
|
||
end
|
||
|
||
function InputText:getTextHeight()
|
||
return self.text_widget:getTextHeight()
|
||
end
|
||
|
||
function InputText:getLineHeight()
|
||
return self.text_widget:getLineHeight()
|
||
end
|
||
|
||
function InputText:getKeyboardDimen()
|
||
return self.readonly and Geom:new{w = 0, h = 0} or self.keyboard.dimen
|
||
end
|
||
|
||
-- calculate current and last (original) line numbers
|
||
function InputText:getLineNums()
|
||
local cur_line_num, last_line_num = 1, 1
|
||
for i = 1, #self.charlist do
|
||
if self.text_widget.charlist[i] == "\n" then
|
||
if i < self.charpos then
|
||
cur_line_num = cur_line_num + 1
|
||
end
|
||
last_line_num = last_line_num + 1
|
||
end
|
||
end
|
||
return cur_line_num, last_line_num
|
||
end
|
||
|
||
-- calculate charpos for the beginning of (original) line
|
||
function InputText:getLineCharPos(line_num)
|
||
local char_pos = 1
|
||
if line_num > 1 then
|
||
local j = 1
|
||
for i = 1, #self.charlist do
|
||
if self.charlist[i] == "\n" then
|
||
j = j + 1
|
||
if j == line_num then
|
||
char_pos = i + 1
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return char_pos
|
||
end
|
||
|
||
-- Get start and end positions of a line (or a word) under the cursor.
|
||
function InputText:getStringPos(is_word, left_to_cursor)
|
||
local delimiter = is_word and "[\n\r%s.,;:!?–—―]" or "[\n\r]"
|
||
local start_pos, end_pos
|
||
if self.charpos > 1 then
|
||
for i = self.charpos - 1, 1, -1 do
|
||
if self.charlist[i]:find(delimiter) then
|
||
start_pos = i + 1
|
||
break
|
||
end
|
||
end
|
||
end
|
||
if left_to_cursor then
|
||
end_pos = self.charpos - 1
|
||
else
|
||
if self.charpos <= #self.charlist then
|
||
for i = self.charpos, #self.charlist do
|
||
if self.charlist[i]:find(delimiter) then
|
||
end_pos = i - 1
|
||
break
|
||
end
|
||
end
|
||
end
|
||
end
|
||
return start_pos or 1, end_pos or #self.charlist
|
||
end
|
||
|
||
--- Return the character at the given offset. If is_absolute is truthy then the
|
||
-- offset is the absolute position, otherwise the offset is added to the current
|
||
-- cursor position (negative offsets are allowed).
|
||
function InputText:getChar(offset, is_absolute)
|
||
local idx = is_absolute and offset or self.charpos + offset
|
||
return self.charlist[idx]
|
||
end
|
||
|
||
function InputText:addChars(chars)
|
||
if self.enter_callback and chars == "\n" then
|
||
UIManager:scheduleIn(0.3, function() self.enter_callback() end)
|
||
return
|
||
end
|
||
|
||
if self.readonly or not self:isTextEditable(true) then
|
||
return
|
||
end
|
||
if #self.charlist == 0 then -- widget text is empty or a hint text is displayed
|
||
self.charpos = 1 -- move cursor to the first position
|
||
end
|
||
local added_charlist = util.splitToChars(chars)
|
||
for i = #added_charlist, 1, -1 do
|
||
table.insert(self.charlist, self.charpos, added_charlist[i])
|
||
end
|
||
self.charpos = self.charpos + #added_charlist
|
||
self.is_text_edited = true
|
||
self:initTextBox(nil, true)
|
||
end
|
||
dbg:guard(InputText, "addChars",
|
||
function(self, chars)
|
||
assert(type(chars) == "string",
|
||
"Wrong chars value type (expected string)!")
|
||
end)
|
||
|
||
function InputText:delChar()
|
||
if self.readonly or not self:isTextEditable(true) then
|
||
return
|
||
end
|
||
if self.charpos == 1 then return end
|
||
self.charpos = self.charpos - 1
|
||
table.remove(self.charlist, self.charpos)
|
||
self.is_text_edited = true
|
||
self:initTextBox()
|
||
end
|
||
|
||
function InputText:delNextChar()
|
||
if self.readonly or not self:isTextEditable(true) then
|
||
return
|
||
end
|
||
if self.charpos > #self.charlist then return end
|
||
table.remove(self.charlist, self.charpos)
|
||
self.is_text_edited = true
|
||
self:initTextBox()
|
||
end
|
||
|
||
function InputText:delWord(left_to_cursor)
|
||
if self.readonly or not self:isTextEditable(true) then
|
||
return
|
||
end
|
||
local start_pos, end_pos = self:getStringPos(true, left_to_cursor)
|
||
for i = end_pos, start_pos, -1 do
|
||
table.remove(self.charlist, i)
|
||
end
|
||
if #self.charlist > 0 then
|
||
local prev_pos = start_pos > 1 and start_pos - 1 or 1
|
||
if not left_to_cursor and self.charlist[prev_pos]:find("[ \t]") then -- remove redundant space
|
||
table.remove(self.charlist, prev_pos)
|
||
self.charpos = prev_pos
|
||
else
|
||
self.charpos = start_pos
|
||
end
|
||
end
|
||
self.is_text_edited = true
|
||
self:initTextBox()
|
||
end
|
||
|
||
function InputText:delToStartOfLine()
|
||
if self.readonly or not self:isTextEditable(true) then
|
||
return
|
||
end
|
||
if self.charpos == 1 then return end
|
||
-- self.charlist[self.charpos] is the char after the cursor
|
||
if self.charlist[self.charpos-1] == "\n" then
|
||
-- If at start of line, just remove the \n and join the previous line
|
||
self.charpos = self.charpos - 1
|
||
table.remove(self.charlist, self.charpos)
|
||
else
|
||
-- If not, remove chars until first found \n (but keeping it)
|
||
while self.charpos > 1 and self.charlist[self.charpos-1] ~= "\n" do
|
||
self.charpos = self.charpos - 1
|
||
table.remove(self.charlist, self.charpos)
|
||
end
|
||
end
|
||
self.is_text_edited = true
|
||
self:initTextBox()
|
||
end
|
||
|
||
function InputText:delAll()
|
||
if self.readonly or not self:isTextEditable(true) then
|
||
return
|
||
end
|
||
if #self.charlist == 0 then return end
|
||
self.charlist = {}
|
||
self.is_text_edited = true
|
||
self:initTextBox()
|
||
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:resyncPos()
|
||
end
|
||
|
||
function InputText:rightChar()
|
||
if self.charpos > #self.charlist then return end
|
||
self.text_widget:moveCursorRight()
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:goToStartOfLine()
|
||
local new_pos = self:getStringPos()
|
||
self.text_widget:moveCursorToCharPos(new_pos)
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:goToEndOfLine()
|
||
local _, new_pos = self:getStringPos()
|
||
self.text_widget:moveCursorToCharPos(new_pos + 1)
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:goToHome()
|
||
self.text_widget:moveCursorHome()
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:goToEnd()
|
||
self.text_widget:moveCursorEnd()
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:moveCursorToCharPos(char_pos)
|
||
self.text_widget:moveCursorToCharPos(char_pos)
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:upLine()
|
||
self.text_widget:moveCursorUp()
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:downLine()
|
||
if #self.charlist == 0 then return end -- Avoid cursor moving within a hint.
|
||
self.text_widget:moveCursorDown()
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:scrollDown()
|
||
self.text_widget:scrollDown()
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:scrollUp()
|
||
self.text_widget:scrollUp()
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:scrollToTop()
|
||
self.text_widget:scrollToTop()
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:scrollToBottom()
|
||
self.text_widget:scrollToBottom()
|
||
self:resyncPos()
|
||
end
|
||
|
||
function InputText:getText()
|
||
return self.text
|
||
end
|
||
|
||
function InputText:setText(text, keep_edited_state)
|
||
-- Keep previous charpos and top_line_num
|
||
self.charlist = util.splitToChars(text)
|
||
self:initTextBox(text)
|
||
if not keep_edited_state then
|
||
-- assume new text is set by caller, and we start fresh
|
||
self.is_text_edited = false
|
||
self:checkTextEditability()
|
||
end
|
||
end
|
||
dbg:guard(InputText, "setText",
|
||
function(self, text, keep_edited_state)
|
||
assert(type(text) == "string",
|
||
"Wrong text type (expected string)")
|
||
end)
|
||
|
||
return InputText
|