2
0
mirror of https://github.com/koreader/koreader synced 2024-11-11 19:11:14 +00:00
koreader/frontend/ui/widget/textviewer.lua
poire-z 945a1cc76f TextViewer: add support for long-press on text
As done in DictQuickLookup (more event handlers are
needed to cohabitate well with MovableContainer).
By default, selected word or text is copied to clipboard.
Also provide indexes to any long-press callback, as we'll
need them for View HTML.
2023-03-05 23:42:24 +01:00

522 lines
18 KiB
Lua

--[[--
Displays some text in a scrollable view.
@usage
local textviewer = TextViewer:new{
title = _("I can scroll!"),
text = _("I'll need to be longer than this example to scroll."),
}
UIManager:show(textviewer)
]]
local BD = require("ui/bidi")
local Blitbuffer = require("ffi/blitbuffer")
local ButtonTable = require("ui/widget/buttontable")
local CenterContainer = require("ui/widget/container/centercontainer")
local CheckButton = require("ui/widget/checkbutton")
local Device = require("device")
local Geom = require("ui/geometry")
local Font = require("ui/font")
local FrameContainer = require("ui/widget/container/framecontainer")
local GestureRange = require("ui/gesturerange")
local InputContainer = require("ui/widget/container/inputcontainer")
local InputDialog = require("ui/widget/inputdialog")
local MovableContainer = require("ui/widget/container/movablecontainer")
local Notification = require("ui/widget/notification")
local ScrollTextWidget = require("ui/widget/scrolltextwidget")
local Size = require("ui/size")
local TitleBar = require("ui/widget/titlebar")
local UIManager = require("ui/uimanager")
local VerticalGroup = require("ui/widget/verticalgroup")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local T = require("ffi/util").template
local util = require("util")
local _ = require("gettext")
local Screen = Device.screen
local TextViewer = InputContainer:extend{
title = nil,
text = nil,
width = nil,
height = nil,
buttons_table = nil,
-- See TextBoxWidget for details about these options
-- We default to justified and auto_para_direction to adapt
-- to any kind of text we are given (book descriptions,
-- bookmarks' text, translation results...).
-- When used to display more technical text (HTML, CSS,
-- application logs...), it's best to reset them to false.
alignment = "left",
justified = true,
lang = nil,
para_direction_rtl = nil,
auto_para_direction = true,
alignment_strict = false,
title_face = nil, -- use default from TitleBar
title_multilines = nil, -- see TitleBar for details
title_shrink_font_to_fit = nil, -- see TitleBar for details
text_face = Font:getFace("x_smallinfofont"),
fgcolor = Blitbuffer.COLOR_BLACK,
text_padding = Size.padding.large,
text_margin = Size.margin.small,
button_padding = Size.padding.default,
-- Bottom row with Close, Find buttons. Also added when no caller's buttons defined.
add_default_buttons = nil,
default_hold_callback = nil, -- on each default button
find_centered_lines_count = 5, -- line with find results to be not far from the center
}
function TextViewer:init()
-- calculate window dimension
self.align = "center"
self.region = Geom:new{
x = 0, y = 0,
w = Screen:getWidth(),
h = Screen:getHeight(),
}
self.width = self.width or Screen:getWidth() - Screen:scaleBySize(30)
self.height = self.height or Screen:getHeight() - Screen:scaleBySize(30)
self._find_next = false
self._find_next_button = false
self._old_virtual_line_num = 1
if Device:hasKeys() then
self.key_events.Close = { { Device.input.group.Back } }
end
if Device:isTouchDevice() then
local range = Geom:new{
x = 0, y = 0,
w = Screen:getWidth(),
h = Screen:getHeight(),
}
self.ges_events = {
TapClose = {
GestureRange:new{
ges = "tap",
range = range,
},
},
Swipe = {
GestureRange:new{
ges = "swipe",
range = range,
},
},
MultiSwipe = {
GestureRange:new{
ges = "multiswipe",
range = range,
},
},
-- Allow selection of one or more words (see textboxwidget.lua):
HoldStartText = {
GestureRange:new{
ges = "hold",
range = range,
},
},
HoldPanText = {
GestureRange:new{
ges = "hold",
range = range,
},
},
HoldReleaseText = {
GestureRange:new{
ges = "hold_release",
range = range,
},
-- callback function when HoldReleaseText is handled as args
args = function(text, hold_duration, start_idx, end_idx, to_source_index_func)
self:handleTextSelection(text, hold_duration, start_idx, end_idx, to_source_index_func)
end
},
-- These will be forwarded to MovableContainer after some checks
ForwardingTouch = { GestureRange:new{ ges = "touch", range = range, }, },
ForwardingPan = { GestureRange:new{ ges = "pan", range = range, }, },
ForwardingPanRelease = { GestureRange:new{ ges = "pan_release", range = range, }, },
}
end
local titlebar = TitleBar:new{
width = self.width,
align = "left",
with_bottom_line = true,
title = self.title,
title_face = self.title_face,
title_multilines = self.title_multilines,
title_shrink_font_to_fit = self.title_shrink_font_to_fit,
close_callback = function() self:onClose() end,
show_parent = self,
}
-- 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 function button_update(id, enable)
local button = self.button_table:getButtonById(id)
if button then
if enable then
button:enable()
else
button:disable()
end
button:refresh()
end
end
self._buttons_scroll_callback = function(low, high)
if prev_at_top and low > 0 then
button_update("top", true)
prev_at_top = false
elseif not prev_at_top and low <= 0 then
button_update("top", false)
prev_at_top = true
end
if prev_at_bottom and high < 1 then
button_update("bottom", true)
prev_at_bottom = false
elseif not prev_at_bottom and high >= 1 then
button_update("bottom", false)
prev_at_bottom = true
end
end
-- buttons
local default_buttons =
{
{
text = _("Find"),
id = "find",
callback = function()
if self._find_next then
self:findCallback()
else
self:findDialog()
end
end,
hold_callback = function()
if self._find_next then
self:findDialog()
else
if self.default_hold_callback then
self.default_hold_callback()
end
end
end,
},
{
text = "",
id = "top",
callback = function()
self.scroll_text_w:scrollToTop()
end,
hold_callback = self.default_hold_callback,
allow_hold_when_disabled = true,
},
{
text = "",
id = "bottom",
callback = function()
self.scroll_text_w:scrollToBottom()
end,
hold_callback = self.default_hold_callback,
allow_hold_when_disabled = true,
},
{
text = _("Close"),
callback = function()
self:onClose()
end,
hold_callback = self.default_hold_callback,
},
}
local buttons = self.buttons_table or {}
if self.add_default_buttons or not self.buttons_table then
table.insert(buttons, default_buttons)
end
self.button_table = ButtonTable:new{
width = self.width - 2*self.button_padding,
buttons = buttons,
zero_sep = true,
show_parent = self,
}
local textw_height = self.height - titlebar:getHeight() - self.button_table:getSize().h
self.scroll_text_w = ScrollTextWidget:new{
text = self.text,
face = self.text_face,
fgcolor = self.fgcolor,
width = self.width - 2*self.text_padding - 2*self.text_margin,
height = textw_height - 2*self.text_padding -2*self.text_margin,
dialog = self,
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,
scroll_callback = self._buttons_scroll_callback,
}
self.textw = FrameContainer:new{
padding = self.text_padding,
margin = self.text_margin,
bordersize = 0,
self.scroll_text_w
}
self.frame = FrameContainer:new{
radius = Size.radius.window,
padding = 0,
margin = 0,
background = Blitbuffer.COLOR_WHITE,
VerticalGroup:new{
titlebar,
CenterContainer:new{
dimen = Geom:new{
w = self.width,
h = self.textw:getSize().h,
},
self.textw,
},
CenterContainer:new{
dimen = Geom:new{
w = self.width,
h = self.button_table:getSize().h,
},
self.button_table,
}
}
}
self.movable = MovableContainer:new{
-- We'll handle these events ourselves, and call appropriate
-- MovableContainer's methods when we didn't process the event
ignore_events = {
-- These have effects over the text widget, and may
-- or may not be processed by it
"swipe", "hold", "hold_release", "hold_pan",
-- These do not have direct effect over the text widget,
-- but may happen while selecting text: we need to check
-- a few things before forwarding them
"touch", "pan", "pan_release",
},
self.frame,
}
self[1] = WidgetContainer:new{
align = self.align,
dimen = self.region,
self.movable,
}
end
function TextViewer:onCloseWidget()
UIManager:setDirty(nil, function()
return "partial", self.frame.dimen
end)
end
function TextViewer:onShow()
UIManager:setDirty(self, function()
return "partial", self.frame.dimen
end)
return true
end
function TextViewer:onTapClose(arg, ges_ev)
if ges_ev.pos:notIntersectWith(self.frame.dimen) then
self:onClose()
end
return true
end
function TextViewer:onMultiSwipe(arg, ges_ev)
-- For consistency with other fullscreen widgets where swipe south can't be
-- used to close and where we then allow any multiswipe to close, allow any
-- multiswipe to close this widget too.
self:onClose()
return true
end
function TextViewer:onClose()
UIManager:close(self)
if self.close_callback then
self.close_callback()
end
return true
end
function TextViewer:onSwipe(arg, ges)
if ges.pos:intersectWith(self.textw.dimen) then
local direction = BD.flipDirectionIfMirroredUILayout(ges.direction)
if direction == "west" then
self.scroll_text_w:scrollText(1)
return true
elseif direction == "east" then
self.scroll_text_w:scrollText(-1)
return true
else
-- trigger a full-screen HQ flashing refresh
UIManager:setDirty(nil, "full")
-- a long diagonal swipe may also be used for taking a screenshot,
-- so let it propagate
return false
end
end
-- Let our MovableContainer handle swipe outside of text
return self.movable:onMovableSwipe(arg, ges)
end
-- The following handlers are similar to the ones in DictQuickLookup:
-- we just forward to our MoveableContainer the events that our
-- TextBoxWidget has not handled with text selection.
function TextViewer:onHoldStartText(_, ges)
-- Forward Hold events not processed by TextBoxWidget event handler
-- to our MovableContainer
return self.movable:onMovableHold(_, ges)
end
function TextViewer:onHoldPanText(_, ges)
-- Forward Hold events not processed by TextBoxWidget event handler
-- to our MovableContainer
-- We only forward it if we did forward the Touch
if self.movable._touch_pre_pan_was_inside then
return self.movable:onMovableHoldPan(arg, ges)
end
end
function TextViewer:onHoldReleaseText(_, ges)
-- Forward Hold events not processed by TextBoxWidget event handler
-- to our MovableContainer
return self.movable:onMovableHoldRelease(_, ges)
end
-- These 3 event processors are just used to forward these events
-- to our MovableContainer, under certain conditions, to avoid
-- unwanted moves of the window while we are selecting text in
-- the definition widget.
function TextViewer:onForwardingTouch(arg, ges)
-- This Touch may be used as the Hold we don't get (for example,
-- when we start our Hold on the bottom buttons)
if not ges.pos:intersectWith(self.textw.dimen) then
return self.movable:onMovableTouch(arg, ges)
else
-- Ensure this is unset, so we can use it to not forward HoldPan
self.movable._touch_pre_pan_was_inside = false
end
end
function TextViewer:onForwardingPan(arg, ges)
-- We only forward it if we did forward the Touch or are currently moving
if self.movable._touch_pre_pan_was_inside or self.movable._moving then
return self.movable:onMovablePan(arg, ges)
end
end
function TextViewer:onForwardingPanRelease(arg, ges)
-- We can forward onMovablePanRelease() does enough checks
return self.movable:onMovablePanRelease(arg, ges)
end
function TextViewer:findDialog()
local input_dialog
input_dialog = InputDialog:new{
title = _("Enter text to search for"),
input = self.search_value,
buttons = {
{
{
text = _("Cancel"),
callback = function()
UIManager:close(input_dialog)
end,
},
{
text = _("Find first"),
callback = function()
self._find_next = false
self:findCallback(input_dialog)
end,
},
{
text = _("Find next"),
is_enter_default = true,
callback = function()
self._find_next = true
self:findCallback(input_dialog)
end,
},
},
},
}
self.check_button_case = CheckButton:new{
text = _("Case sensitive"),
checked = self.case_sensitive,
parent = input_dialog,
callback = function()
self.case_sensitive = self.check_button_case.checked
end,
}
input_dialog:addWidget(self.check_button_case)
UIManager:show(input_dialog)
input_dialog:onShowKeyboard(true)
end
function TextViewer:findCallback(input_dialog)
if input_dialog then
self.search_value = input_dialog:getInputText()
if self.search_value == "" then return end
UIManager:close(input_dialog)
end
local start_pos = 1
if self._find_next then
local charpos, new_virtual_line_num = self.scroll_text_w:getCharPos()
if math.abs(new_virtual_line_num - self._old_virtual_line_num) > self.find_centered_lines_count then
start_pos = self.scroll_text_w:getCharPosAtXY(0, 0) -- first char of the top line
else
start_pos = (charpos or 0) + 1 -- previous search result
end
end
local char_pos = util.stringSearch(self.text, self.search_value, self.case_sensitive, start_pos)
local msg
if char_pos > 0 then
self.scroll_text_w:moveCursorToCharPos(char_pos, self.find_centered_lines_count)
msg = T(_("Found, screen line %1."), self.scroll_text_w:getCharPosLineNum())
self._find_next = true
self._old_virtual_line_num = select(2, self.scroll_text_w:getCharPos())
else
msg = _("Not found.")
self._find_next = false
self._old_virtual_line_num = 1
end
UIManager:show(Notification:new{
text = msg,
})
if self._find_next_button ~= self._find_next then
self._find_next_button = self._find_next
local button_text = self._find_next and _("Find next") or _("Find")
local find_button = self.button_table:getButtonById("find")
find_button:setText(button_text, find_button.width)
find_button:refresh()
end
end
function TextViewer:handleTextSelection(text, hold_duration, start_idx, end_idx, to_source_index_func)
if self.text_selection_callback then
self.text_selection_callback(text, hold_duration, start_idx, end_idx, to_source_index_func)
return
end
if Device:hasClipboard() then
Device.input.setClipboardText(text)
UIManager:show(Notification:new{
text = start_idx == end_idx and _("Word copied to clipboard.")
or _("Selection copied to clipboard."),
})
end
end
return TextViewer