mirror of
https://github.com/koreader/koreader
synced 2024-10-31 21:20:20 +00:00
6d53f83286
* ReaderDictionary: Port delay computations to TimeVal * ReaderHighlight: Port delay computations to TimeVal * ReaderView: Port delay computations to TimeVal * Android: Reset gesture detection state on APP_CMD_TERM_WINDOW. This prevents potentially being stuck in bogus gesture states when switching apps. * GestureDetector: * Port delay computations to TimeVal * Fixed delay computations to handle time warps (large and negative deltas). * Simplified timed callback handling to invalidate timers much earlier, preventing accumulating useless timers that no longer have any chance of ever detecting a gesture. * Fixed state clearing to handle the actual effective slots, instead of hard-coding slot 0 & slot 1. * Simplified timed callback handling in general, and added support for a timerfd backend for better performance and accuracy. * The improved timed callback handling allows us to detect and honor (as much as possible) the three possible clock sources usable by Linux evdev events. The only case where synthetic timestamps are used (and that only to handle timed callbacks) is limited to non-timerfd platforms where input events use a clock source that is *NOT* MONOTONIC. AFAICT, that's pretty much... PocketBook, and that's it? * Input: * Use the <linux/input.h> FFI module instead of re-declaring every constant * Fixed (verbose) debug logging of input events to actually translate said constants properly. * Completely reset gesture detection state on suspend. This should prevent bogus gesture detection on resume. * Refactored the waitEvent loop to make it easier to comprehend (hopefully) and much more efficient. Of specific note, it no longer does a crazy select spam every 100µs, instead computing and relying on sane timeouts, as afforded by switching the UI event/input loop to the MONOTONIC time base, and the refactored timed callbacks in GestureDetector. * reMarkable: Stopped enforcing synthetic timestamps on input events, as it should no longer be necessary. * TimeVal: * Refactored and simplified, especially as far as metamethods are concerned (based on <bsd/sys/time.h>). * Added a host of new methods to query the various POSIX clock sources, and made :now default to MONOTONIC. * Removed the debug guard in __sub, as time going backwards can be a perfectly normal occurrence. * New methods: * Clock sources: :realtime, :monotonic, :monotonic_coarse, :realtime_coarse, :boottime * Utility: :tonumber, :tousecs, :tomsecs, :fromnumber, :isPositive, :isZero * UIManager: * Ported event loop & scheduling to TimeVal, and switched to the MONOTONIC time base. This ensures reliable and consistent scheduling, as time is ensured never to go backwards. * Added a :getTime() method, that returns a cached TimeVal:now(), updated at the top of every UI frame. It's used throughout the codebase to cadge a syscall in circumstances where we are guaranteed that a syscall would return a mostly identical value, because very few time has passed. The only code left that does live syscalls does it because it's actually necessary for accuracy, and the only code left that does that in a REALTIME time base is code that *actually* deals with calendar time (e.g., Statistics). * DictQuickLookup: Port delay computations to TimeVal * FootNoteWidget: Port delay computations to TimeVal * HTMLBoxWidget: Port delay computations to TimeVal * Notification: Port delay computations to TimeVal * TextBoxWidget: Port delay computations to TimeVal * AutoSuspend: Port to TimeVal * AutoTurn: * Fix it so that settings are actually honored. * Port to TimeVal * BackgroundRunner: Port to TimeVal * Calibre: Port benchmarking code to TimeVal * BookInfoManager: Removed unnecessary yield in the metadata extraction subprocess now that subprocesses get scheduled properly. * All in all, these changes reduced the CPU cost of a single tap by a factor of ten (!), and got rid of an insane amount of weird poll/wakeup cycles that must have been hell on CPU schedulers and batteries..
1994 lines
85 KiB
Lua
1994 lines
85 KiB
Lua
--[[--
|
|
A TextWidget that handles long text wrapping
|
|
|
|
Example:
|
|
|
|
local Foo = TextBoxWidget:new{
|
|
face = Font:getFace("cfont", 25),
|
|
text = 'We can show multiple lines.\nFoo.\nBar.',
|
|
-- width = math.floor(Screen:getWidth() * 2/3),
|
|
}
|
|
UIManager:show(Foo)
|
|
|
|
]]
|
|
|
|
local Blitbuffer = require("ffi/blitbuffer")
|
|
local Device = require("device")
|
|
local Font = require("ui/font")
|
|
local FrameContainer = require("ui/widget/container/framecontainer")
|
|
local Geom = require("ui/geometry")
|
|
local GestureRange = require("ui/gesturerange")
|
|
local InputContainer = require("ui/widget/container/inputcontainer")
|
|
local LineWidget = require("ui/widget/linewidget")
|
|
local RenderText = require("ui/rendertext")
|
|
local RightContainer = require("ui/widget/container/rightcontainer")
|
|
local Size = require("ui/size")
|
|
local TextWidget = require("ui/widget/textwidget")
|
|
local UIManager = require("ui/uimanager")
|
|
local Math = require("optmath")
|
|
local logger = require("logger")
|
|
local util = require("util")
|
|
local Screen = require("device").screen
|
|
|
|
local TextBoxWidget = InputContainer:new{
|
|
text = nil,
|
|
editable = false, -- Editable flag for whether drawing the cursor or not.
|
|
justified = false, -- Should text be justified (spaces widened to fill width)
|
|
alignment = "left", -- or "center", "right"
|
|
dialog = nil, -- parent dialog that will be set dirty
|
|
face = nil,
|
|
bold = nil, -- use bold=true to use a real bold font (or synthetized if not available),
|
|
-- or bold=Font.FORCE_SYNTHETIZED_BOLD to force using synthetized bold,
|
|
-- which, with XText, makes a bold string the same width as it non-bolded.
|
|
line_height = 0.3, -- in em
|
|
fgcolor = Blitbuffer.COLOR_BLACK,
|
|
width = Screen:scaleBySize(400), -- in pixels
|
|
height = nil, -- nil value indicates unscrollable text widget
|
|
height_adjust = false, -- if true, reduce height to a multiple of line_height (for nicer centering)
|
|
height_overflow_show_ellipsis = false, -- if height overflow, append ellipsis to last shown line
|
|
top_line_num = nil, -- original virtual_line_num to scroll to
|
|
charpos = nil, -- idx of char to draw the cursor on its left (can exceed #charlist by 1)
|
|
|
|
-- for internal use
|
|
charlist = nil, -- idx => char
|
|
char_width = nil, -- char => width
|
|
idx_pad = nil, -- idx => pad for char at idx, if non zero
|
|
vertical_string_list = nil,
|
|
virtual_line_num = 1, -- index of the top displayed line
|
|
line_height_px = nil, -- height of a line in px
|
|
lines_per_page = nil, -- number of visible lines
|
|
text_height = nil, -- adjusted height to visible text (lines_per_page*line_height_px)
|
|
cursor_line = nil, -- LineWidget to draw the vertical cursor.
|
|
_bb = nil,
|
|
_face_adjusted = nil,
|
|
|
|
-- We can provide a list of images: each image will be displayed on each
|
|
-- scrolled page, in its top right corner (if more images than pages, remaining
|
|
-- images will not be displayed at all - if more pages than images, remaining
|
|
-- pages won't have any image).
|
|
-- Each 'image' is a table with the following keys:
|
|
-- width width of small image displayed by us
|
|
-- height height of small image displayed by us
|
|
-- bb blitbuffer of small image, may be initially nil
|
|
-- optional:
|
|
-- hi_width same as previous for a high-resolution version of the
|
|
-- hi_height image, to be displayed by ImageViewer when Hold on
|
|
-- hi_bb blitbuffer of high-resolution image
|
|
-- title ImageViewer title
|
|
-- caption ImageViewer caption
|
|
--
|
|
-- load_bb_func function called (with one arg: false to load 'bb', true to load 'hi_bb)
|
|
-- when bb or hi_bb is nil: its job is to load/build bb or hi_bb.
|
|
-- The page will refresh itself when load_bb_func returns.
|
|
images = nil, -- list of such images
|
|
line_num_to_image = nil, -- will be filled by self:_splitToLines()
|
|
image_padding_left = Screen:scaleBySize(10),
|
|
image_padding_bottom = Screen:scaleBySize(3),
|
|
image_alt_face = Font:getFace("xx_smallinfofont"),
|
|
image_alt_fgcolor = Blitbuffer.COLOR_BLACK,
|
|
scroll_force_to_page = false, -- will be forced to true if images
|
|
|
|
-- Additional properties only used when using xtext
|
|
use_xtext = G_reader_settings:nilOrTrue("use_xtext"),
|
|
lang = nil, -- use this language (string) instead of the UI language
|
|
para_direction_rtl = nil, -- use true/false to override the default direction for the UI language
|
|
auto_para_direction = false, -- detect direction of each paragraph in text
|
|
-- (para_direction_rtl or UI language is then only
|
|
-- used as a weak hint about direction)
|
|
alignment_strict = false, -- true to force the alignemnt set by the alignment= attribute.
|
|
-- When false, specified alignment is inverted when para direction is RTL
|
|
tabstop_nb_space_width = 8, -- unscaled_size_check: ignore
|
|
-- width of tabstops, as a factor of the width of a space
|
|
-- (set to 0 to disable any tab handling and display a tofu glyph)
|
|
_xtext = nil, -- for internal use
|
|
_alt_color_for_rtl = nil, -- (for debugging) draw LTR glyphs in black, RTL glyphs in gray
|
|
|
|
-- for internal use
|
|
for_measurement_only = nil, -- When the widget is a one-off used to compute text height
|
|
}
|
|
|
|
function TextBoxWidget:init()
|
|
if not self._face_adjusted then
|
|
self._face_adjusted = true -- only do that once
|
|
-- If self.bold, or if self.face is a real bold face, we may need to use
|
|
-- an alternative instance of self.face, with possibly the associated
|
|
-- real bold font, and/or with tweaks so fallback fonts are rendered bold
|
|
-- too, without affecting the regular self.face
|
|
self.face, self.bold = Font:getAdjustedFace(self.face, self.bold)
|
|
end
|
|
|
|
self.line_height_px = Math.round( (1 + self.line_height) * self.face.size )
|
|
self.cursor_line = LineWidget:new{
|
|
dimen = Geom:new{
|
|
w = Size.line.medium,
|
|
h = self.line_height_px,
|
|
}
|
|
}
|
|
if self.height then
|
|
-- luajit may segfault if we were provided with a negative height
|
|
-- also ensure we display at least one line
|
|
if self.height < self.line_height_px then
|
|
self.height = self.line_height_px
|
|
end
|
|
-- if no self.height, these will be set just after self:_splitToLines()
|
|
self.lines_per_page = math.floor(self.height / self.line_height_px)
|
|
self.text_height = self.lines_per_page * self.line_height_px
|
|
end
|
|
|
|
if self.use_xtext then
|
|
self:_measureWithXText()
|
|
else
|
|
self:_evalCharWidthList()
|
|
end
|
|
self:_splitToLines()
|
|
|
|
if self.charpos and self.charpos > #self.charlist+1 then
|
|
self.charpos = #self.charlist+1
|
|
end
|
|
|
|
if self.height == nil then
|
|
self.lines_per_page = #self.vertical_string_list
|
|
self.text_height = self.lines_per_page * self.line_height_px
|
|
self.virtual_line_num = 1
|
|
else
|
|
if self.height_overflow_show_ellipsis and #self.vertical_string_list > self.lines_per_page then
|
|
self.line_with_ellipsis = self.lines_per_page
|
|
end
|
|
if self.height_adjust then
|
|
self.height = self.text_height
|
|
if #self.vertical_string_list < self.lines_per_page then
|
|
self.height = #self.vertical_string_list * self.line_height_px
|
|
end
|
|
end
|
|
-- Show the previous displayed area in case of re-init (focus/unfocus)
|
|
-- InputText may have re-created us, while providing the previous charlist,
|
|
-- charpos and top_line_num.
|
|
-- We need to show the line containing charpos, while trying to
|
|
-- keep the previous top_line_num
|
|
if self.editable and self.charpos then
|
|
self:scrollViewToCharPos()
|
|
end
|
|
end
|
|
self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
|
|
if self.editable then
|
|
self:moveCursorToCharPos(self.charpos or 1)
|
|
end
|
|
self.dimen = Geom:new(self:getSize())
|
|
if Device:isTouchDevice() then
|
|
self.ges_events = {
|
|
TapImage = {
|
|
GestureRange:new{
|
|
ges = "tap",
|
|
range = function() return self.dimen end,
|
|
},
|
|
},
|
|
}
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:unfocus()
|
|
self.editable = false
|
|
self:free()
|
|
self:init()
|
|
end
|
|
|
|
function TextBoxWidget:focus()
|
|
self.editable = true
|
|
self:free()
|
|
self:init()
|
|
end
|
|
|
|
-- Split `self.text` into `self.charlist` and evaluate the width of each char in it.
|
|
function TextBoxWidget:_evalCharWidthList()
|
|
-- if self.charlist is provided, use it directly
|
|
if self.charlist == nil then
|
|
self.charlist = util.splitToChars(self.text)
|
|
end
|
|
-- get width of each distinct char
|
|
local char_width = {}
|
|
for _, c in ipairs(self.charlist) do
|
|
if not char_width[c] then
|
|
char_width[c] = RenderText:sizeUtf8Text(0, Screen:getWidth(), self.face, c, true, self.bold).x
|
|
end
|
|
end
|
|
self.char_width = char_width
|
|
self.idx_pad = {}
|
|
end
|
|
|
|
function TextBoxWidget:_measureWithXText()
|
|
if not self._xtext_loaded then
|
|
require("libs/libkoreader-xtext")
|
|
TextBoxWidget._xtext_loaded = true
|
|
end
|
|
if type(self.charlist) == "table" then
|
|
self._xtext = xtext.new(self.charlist, self.face, self.auto_para_direction,
|
|
self.para_direction_rtl, self.lang)
|
|
else
|
|
if not self.text then
|
|
self.text = ""
|
|
elseif type(self.text) ~= "string" then
|
|
self.text = tostring(self.text)
|
|
end
|
|
self._xtext = xtext.new(self.text, self.face, self.auto_para_direction,
|
|
self.para_direction_rtl, self.lang)
|
|
self.charlist = self._xtext
|
|
-- Just to have many common bits of code using #self.charlist work
|
|
-- as expected (will crash if used as a real table with "table
|
|
-- expected, got userdata", so we know)
|
|
end
|
|
self._xtext:measure()
|
|
if self.tabstop_nb_space_width > 0 and not self._tabstop_width then
|
|
local space_width = RenderText:sizeUtf8Text(0, false, self.face, " ").x
|
|
self._tabstop_width = self.tabstop_nb_space_width * space_width
|
|
end
|
|
end
|
|
|
|
-- Split the text into logical lines to fit into the text box.
|
|
function TextBoxWidget:_splitToLines()
|
|
self.vertical_string_list = {}
|
|
self.line_num_to_image = nil
|
|
|
|
local idx = 1
|
|
local size = #self.charlist
|
|
local ln = 1
|
|
local offset, end_offset, cur_line_width
|
|
|
|
if self.images and #self.images > 0 then
|
|
-- Force scrolling to align to top of pages, as we
|
|
-- expect to draw images only at top of view
|
|
self.scroll_force_to_page = true
|
|
end
|
|
local image_num = 0
|
|
local targeted_width = self.width
|
|
local image_lines_remaining = 0
|
|
while idx and idx <= size do
|
|
-- Every scrolled page, we want to add the next (if any) image at its top right
|
|
-- (if not scrollable, we will display only the first image)
|
|
-- We need to make shorter lines and leave room for the image
|
|
if self.images and #self.images > 0 then
|
|
if self.line_num_to_image == nil then
|
|
self.line_num_to_image = {}
|
|
end
|
|
if (self.lines_per_page and ln % self.lines_per_page == 1) -- first line of a scrolled page
|
|
or (self.lines_per_page == nil and ln == 1) then -- first line if not scrollabled
|
|
image_num = image_num + 1
|
|
if image_num <= #self.images then
|
|
local image = self.images[image_num]
|
|
self.line_num_to_image[ln] = image
|
|
-- Resize image if really too big: bb will be cropped if already there,
|
|
-- but if loaded later with load_bb_func, load_bb_func may resize it
|
|
-- to the width and height we have updated here.
|
|
if image.width > self.width / 2 then
|
|
image.height = math.floor(image.height * (self.width / 2 / image.width))
|
|
image.width = math.floor(self.width / 2)
|
|
end
|
|
if image.height > self.height / 2 then
|
|
image.width = math.floor(image.width * (self.height / 2 / image.height))
|
|
image.height = math.floor(self.height / 2)
|
|
end
|
|
targeted_width = self.width - image.width - self.image_padding_left
|
|
image_lines_remaining = math.ceil((image.height + self.image_padding_bottom)/self.line_height_px)
|
|
end
|
|
end
|
|
if image_lines_remaining > 0 then
|
|
image_lines_remaining = image_lines_remaining - 1
|
|
else
|
|
targeted_width = self.width -- text can now use full width
|
|
end
|
|
end
|
|
|
|
-- end_offset will be the idx of char at end of line
|
|
offset = idx -- idx of char at start of line
|
|
|
|
if self.use_xtext then
|
|
-- All of what's done below when use_xtext=false is done by the C++ module.
|
|
local line = self._xtext:makeLine(offset, targeted_width, false, self._tabstop_width)
|
|
-- logger.dbg("makeLine", ln, line)
|
|
-- We get a line such as this:
|
|
-- {
|
|
-- ["next_start_offset"] = 9272,
|
|
-- ["width"] = 511,
|
|
-- ["end_offset"] = 9270,
|
|
-- ["targeted_width"] = 548,
|
|
-- ["offset"] = 9208,
|
|
-- ["can_be_justified"] = true
|
|
-- },
|
|
-- Notes:
|
|
-- - next_start_offset is nil when reaching end of text
|
|
-- - On empty lines made from a standalone \n\n, we get end_offset = offset-1,
|
|
-- which is a bit strange but that's what the use_xtext=false does.
|
|
-- - Between a line end_offset= and the next line offset=, there may be only
|
|
-- a single indice not included: the \n or the space that allowed the break.
|
|
--
|
|
if line.next_start_offset and line.next_start_offset == line.offset then
|
|
-- No char could fit (too small targeted_width)
|
|
-- makeLine 6509 { ["offset"] = 1, ["end_offset"] = 0, ["next_start_offset"] = 1,
|
|
-- ["width"] = 0, ["targeted_width"] = 7, ["no_allowed_break_met"] = true, ["can_be_justified"] = true
|
|
-- Make one char on this line
|
|
line.next_start_offset = line.offset + 1
|
|
line.width = targeted_width
|
|
end
|
|
self.vertical_string_list[ln] = line
|
|
if line.no_allowed_break_met then
|
|
-- let the fact a long word was splitted be known
|
|
self.has_split_inside_word = true
|
|
end
|
|
if line.hard_newline_at_eot and not line.next_start_offset then
|
|
-- Add an empty line to reprensent the \n at end of text
|
|
-- and allow positioning cursor after it
|
|
self.vertical_string_list[ln+1] = {
|
|
offset = size+1,
|
|
end_offset = nil,
|
|
width = 0,
|
|
targeted_width = targeted_width,
|
|
}
|
|
end
|
|
ln = ln + 1
|
|
idx = line.next_start_offset -- nil when end of text reached
|
|
-- Skip the whole following non-use_xtext code, to continue
|
|
-- this 'while' loop (to avoid indentation diff on the
|
|
-- following code if we were using a 'else'...)
|
|
goto idx_continue
|
|
end
|
|
|
|
-- Only when not self.use_xtext:
|
|
|
|
-- We append chars until the accumulated width exceeds `targeted_width`,
|
|
-- or a newline occurs, or no more chars to consume.
|
|
cur_line_width = 0
|
|
local hard_newline = false
|
|
while idx <= size do
|
|
if self.charlist[idx] == "\n" then
|
|
hard_newline = true
|
|
break
|
|
end
|
|
cur_line_width = cur_line_width + self.char_width[self.charlist[idx]]
|
|
if cur_line_width > targeted_width then break else idx = idx + 1 end
|
|
end
|
|
if cur_line_width <= targeted_width then -- a hard newline or end of string
|
|
end_offset = idx - 1
|
|
else
|
|
-- Backtrack the string until the length fit into one line.
|
|
-- We'll give next and prev chars to isSplittable() for a wiser decision
|
|
local c = self.charlist[idx]
|
|
local next_c = idx+1 <= size and self.charlist[idx+1] or false
|
|
local prev_c = idx-1 >= 1 and self.charlist[idx-1] or false
|
|
local adjusted_idx = idx
|
|
local adjusted_width = cur_line_width
|
|
while adjusted_idx > offset and not util.isSplittable(c, next_c, prev_c) do
|
|
adjusted_width = adjusted_width - self.char_width[self.charlist[adjusted_idx]]
|
|
adjusted_idx = adjusted_idx - 1
|
|
next_c = c
|
|
c = prev_c
|
|
prev_c = adjusted_idx-1 >= 1 and self.charlist[adjusted_idx-1] or false
|
|
end
|
|
if adjusted_idx == offset or adjusted_idx == idx then
|
|
-- either a very long english word occupying more than one line,
|
|
-- or the excessive char is itself splittable:
|
|
-- we let that excessive char for next line
|
|
if adjusted_idx == offset then -- let the fact a long word was splitted be known
|
|
self.has_split_inside_word = true
|
|
end
|
|
end_offset = idx - 1
|
|
cur_line_width = cur_line_width - self.char_width[self.charlist[idx]]
|
|
elseif c == " " then
|
|
-- we backtracked and we're below max width, but the last char
|
|
-- is a space, we can ignore it
|
|
end_offset = adjusted_idx - 1
|
|
cur_line_width = adjusted_width - self.char_width[self.charlist[adjusted_idx]]
|
|
idx = adjusted_idx + 1
|
|
else
|
|
-- we backtracked and we're below max width, we can leave the
|
|
-- splittable char on this line
|
|
end_offset = adjusted_idx
|
|
cur_line_width = adjusted_width
|
|
idx = adjusted_idx + 1
|
|
end
|
|
if self.justified then
|
|
-- this line was splitted and can be justified
|
|
-- we record in idx_pad the nb of pixels to add to each char
|
|
-- to make the whole line justified. This also helps hold
|
|
-- position accuracy.
|
|
local fill_width = targeted_width - cur_line_width
|
|
if fill_width > 0 then
|
|
local nbspaces = 0
|
|
for sidx = offset, end_offset do
|
|
if self.charlist[sidx] == " " then
|
|
nbspaces = nbspaces + 1
|
|
end
|
|
end
|
|
if nbspaces > 0 then
|
|
-- width added to all spaces
|
|
local space_add_w = math.floor(fill_width / nbspaces)
|
|
-- nb of spaces to which we'll add 1 more pixel
|
|
local space_add1_nb = fill_width - space_add_w * nbspaces
|
|
for cidx = offset, end_offset do
|
|
local pad
|
|
if self.charlist[cidx] == " " then
|
|
pad = space_add_w
|
|
if space_add1_nb > 0 then
|
|
pad = pad + 1
|
|
space_add1_nb = space_add1_nb - 1
|
|
end
|
|
if pad > 0 then self.idx_pad[cidx] = pad end
|
|
end
|
|
end
|
|
else
|
|
-- very long word, or CJK text with no space
|
|
-- pad first chars with 1 pixel
|
|
for cidx = offset, end_offset do
|
|
if fill_width > 0 then
|
|
self.idx_pad[cidx] = 1
|
|
fill_width = fill_width - 1
|
|
else
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end -- endif cur_line_width > targeted_width
|
|
if cur_line_width < 0 then break end
|
|
self.vertical_string_list[ln] = {
|
|
offset = offset,
|
|
end_offset = end_offset,
|
|
width = cur_line_width,
|
|
}
|
|
if hard_newline then
|
|
idx = idx + 1
|
|
-- end_offset = nil means no text
|
|
self.vertical_string_list[ln+1] = {offset = idx, end_offset = nil, width = 0}
|
|
else
|
|
-- If next char is a space, discard it so it does not become
|
|
-- an ugly leading space on the next line
|
|
if idx <= size and self.charlist[idx] == " " then
|
|
idx = idx + 1
|
|
end
|
|
end
|
|
ln = ln + 1
|
|
-- Make sure `idx` point to the next char to be processed in the next loop.
|
|
|
|
::idx_continue:: -- (Label for goto when use_xtext=true)
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:_getLineText(vertical_string)
|
|
if not vertical_string.end_offset then return "" end
|
|
return table.concat(self.charlist, "", vertical_string.offset, vertical_string.end_offset)
|
|
end
|
|
|
|
function TextBoxWidget:_getLinePads(vertical_string)
|
|
if not vertical_string.end_offset then return end
|
|
local pads = {}
|
|
for idx = vertical_string.offset, vertical_string.end_offset do
|
|
table.insert(pads, self.idx_pad[idx] or 0)
|
|
end
|
|
return pads
|
|
end
|
|
|
|
-- XText: shape a line into positioned glyphs
|
|
function TextBoxWidget:_shapeLine(line)
|
|
-- line is an item from self.vertical_string_list
|
|
if line._shaped then
|
|
return -- already done
|
|
end
|
|
line._shaped = true
|
|
if not line.end_offset or line.end_offset < line.offset then
|
|
-- Empty line (first check above is for hard newline at end of file,
|
|
-- second check is for hard newline while not at end of file).
|
|
-- We need to set a direction on this line, so the cursor can be
|
|
-- positioned accordingly, on the left or on the right of the line
|
|
-- (for convenience, we have an empty line inherit the direction
|
|
-- of the previous line if non-empty)
|
|
local offset = line.offset
|
|
if not line.end_offset then -- last line with offset=#text+1
|
|
if offset > 1 then -- non empty text: get it from last char
|
|
offset = offset - 1
|
|
else
|
|
offset = nil -- no text: get _xtext specified or default direction
|
|
end
|
|
end
|
|
local para_dir_rtl, prev_char_para_dir_rtl = self._xtext:getParaDirection(offset)
|
|
line.para_is_rtl = para_dir_rtl or prev_char_para_dir_rtl
|
|
-- We also need to set x_start & x_end (similar to how we do it below)
|
|
local alignment = self.alignment
|
|
if not self.alignment_strict and line.para_is_rtl then
|
|
if alignment == "left" then
|
|
alignment = "right"
|
|
elseif alignment == "right" then
|
|
alignment = "left"
|
|
end
|
|
end
|
|
local pen_x = 0 -- when alignment == "left"
|
|
if alignment == "center" then
|
|
pen_x = line.targeted_width / 2
|
|
elseif alignment == "right" then
|
|
pen_x = line.targeted_width
|
|
end
|
|
line.x_start = pen_x
|
|
line.x_end = pen_x
|
|
return
|
|
end
|
|
-- Get glyphs, shaped and possibly substituted by Harfbuzz and re-ordered by FriBiDi.
|
|
-- We'll add to 'line' this table of glyphs, with some additional
|
|
-- computed x and advance keys
|
|
local xshaping = self._xtext:shapeLine(line.offset, line.end_offset,
|
|
line.idx_to_substitute_with_ellipsis)
|
|
-- logger.dbg(xshaping)
|
|
-- We get an array of tables looking like this:
|
|
-- [1] = {
|
|
-- ["y_offset"] = 0,
|
|
-- ["x_advance"] = 10,
|
|
-- ["can_extend"] = false,
|
|
-- ["can_extend_fallback"] = false,
|
|
-- ["is_rtl"] = false,
|
|
-- ["bidi_level"] = 0,
|
|
-- ["text_index"] = 1,
|
|
-- ["glyph"] = 68,
|
|
-- ["font_num"] = 0,
|
|
-- ["x_offset"] = 0,
|
|
-- ["is_cluster_start"] = true,
|
|
-- ["cluster_len"] = 1
|
|
-- },
|
|
-- [...]
|
|
-- [12] = {
|
|
-- ["y_offset"] = 0,
|
|
-- ["x_advance"] = 0,
|
|
-- ["can_extend"] = false,
|
|
-- ["can_extend_fallback"] = false,
|
|
-- ["is_rtl"] = true,
|
|
-- ["bidi_level"] = 1,
|
|
-- ["text_index"] = 8,
|
|
-- ["glyph"] = 1292,
|
|
-- ["font_num"] = 3,
|
|
-- ["x_offset"] = -2,
|
|
-- ["is_cluster_start"] = true,
|
|
-- ["cluster_len"] = 2
|
|
-- },
|
|
-- [13] = {
|
|
-- ["y_offset"] = 0,
|
|
-- ["x_advance"] = 10,
|
|
-- ["can_extend"] = false,
|
|
-- ["can_extend_fallback"] = false,
|
|
-- ["is_rtl"] = true,
|
|
-- ["bidi_level"] = 1,
|
|
-- ["text_index"] = 8,
|
|
-- ["glyph"] = 1321,
|
|
-- ["font_num"] = 3,
|
|
-- ["x_offset"] = 0,
|
|
-- ["is_cluster_start"] = false,
|
|
-- ["cluster_len"] = 2
|
|
-- },
|
|
-- With some additional keys about the line itself, that will help
|
|
-- with alignment and justification:
|
|
-- ["para_is_rtl"] = true,
|
|
-- ["nb_can_extend"] = 6,
|
|
-- ["nb_can_extend_fallback"] = 0,
|
|
-- ["width"] = 457
|
|
|
|
local alignment = self.alignment
|
|
if not self.alignment_strict and xshaping.para_is_rtl then
|
|
if alignment == "left" then
|
|
alignment = "right"
|
|
elseif alignment == "right" then
|
|
alignment = "left"
|
|
end
|
|
end
|
|
|
|
if xshaping.has_tabs and self.tabstop_nb_space_width > 0 then
|
|
-- Try to handle tabs: we got offset and end_offset to target the
|
|
-- expected width with tabstops applied on the logical order string.
|
|
-- We can really handle them correctly only with:
|
|
-- - pure LTR text and alignment=left
|
|
-- - pure RTL text and alignment=right
|
|
-- and with some possibly uneven spacing when text is justified.
|
|
-- Hopefully, we shouldn't use right or center with external text,
|
|
-- and our internal text is probably tab-free.
|
|
-- Note that tab is a Unicode SS (Segment Separator), so hopefully,
|
|
-- it seems to always get back the main direction of the paragraph
|
|
-- if text is Bidi - so our tabstops always fly in the main
|
|
-- paragraph direction.
|
|
-- When we can't do well, we let the tab char have its tofu glyph
|
|
-- width (which seems to be around the width of 4 spaces with our
|
|
-- fonts), and we just avoid displaying the glyph. So, there will
|
|
-- be enough spacing, but no tabstop alignment.
|
|
if alignment == "left" and not xshaping.para_is_rtl then
|
|
local last_tab = 0
|
|
local pen_x = 0
|
|
for i=1, #xshaping, 1 do
|
|
local xglyph = xshaping[i]
|
|
if xglyph.is_tab then
|
|
last_tab = i
|
|
local nb_tabstops_passed_by = math.floor(pen_x / self._tabstop_width)
|
|
local new_pen_x = (nb_tabstops_passed_by + 1) * self._tabstop_width
|
|
local this_tab_width = new_pen_x - pen_x
|
|
xshaping.width = xshaping.width - xglyph.x_advance + this_tab_width
|
|
xglyph.x_advance = this_tab_width
|
|
end
|
|
pen_x = pen_x + xglyph.x_advance
|
|
end
|
|
if last_tab > 0 and self.justified and line.can_be_justified then
|
|
-- Remove all can_extend before (on the left of) last tab
|
|
-- so justification does not affect tabstops
|
|
for i=1, last_tab, 1 do
|
|
local xglyph = xshaping[i]
|
|
if xglyph.can_extend then
|
|
xglyph.can_extend = false
|
|
xshaping.nb_can_extend = xshaping.nb_can_extend - 1
|
|
end
|
|
if xglyph.can_extend_fallback then
|
|
xglyph.can_extend_fallback = false
|
|
xshaping.nb_can_extend_fallback = xshaping.nb_can_extend_fallback - 1
|
|
end
|
|
end
|
|
end
|
|
elseif alignment == "right" and xshaping.para_is_rtl then
|
|
-- Similar, but scanning and using a pen_x from the right
|
|
local last_tab = 0
|
|
local pen_x = 0
|
|
for i=#xshaping, 1, -1 do
|
|
local xglyph = xshaping[i]
|
|
if xglyph.is_tab then
|
|
last_tab = i
|
|
local nb_tabstops_passed_by = math.floor(pen_x / self._tabstop_width)
|
|
local new_pen_x = (nb_tabstops_passed_by + 1) * self._tabstop_width
|
|
local this_tab_width = new_pen_x - pen_x
|
|
xshaping.width = xshaping.width - xglyph.x_advance + this_tab_width
|
|
xglyph.x_advance = this_tab_width
|
|
end
|
|
pen_x = pen_x + xglyph.x_advance
|
|
end
|
|
if last_tab > 0 and self.justified and line.can_be_justified then
|
|
-- Remove all can_extend before (on the right of) last tab
|
|
-- so justification does not affect tabstops
|
|
for i=#xshaping, last_tab, -1 do
|
|
local xglyph = xshaping[i]
|
|
if xglyph.can_extend then
|
|
xglyph.can_extend = false
|
|
xshaping.nb_can_extend = xshaping.nb_can_extend - 1
|
|
end
|
|
if xglyph.can_extend_fallback then
|
|
xglyph.can_extend_fallback = false
|
|
xshaping.nb_can_extend_fallback = xshaping.nb_can_extend_fallback - 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local pen_x = 0 -- when alignment == "left"
|
|
if alignment == "center" then
|
|
pen_x = (line.targeted_width - line.width)/2 or 0
|
|
elseif alignment == "right" then
|
|
pen_x = (line.targeted_width - line.width)
|
|
end
|
|
|
|
local space_add_w = 0
|
|
local space_add1_nb = 0
|
|
local use_can_extend_fallback = false
|
|
if self.justified and line.can_be_justified then
|
|
local space_to_fill = line.targeted_width - xshaping.width
|
|
if xshaping.nb_can_extend > 0 then
|
|
space_add_w = math.floor(space_to_fill / xshaping.nb_can_extend)
|
|
-- nb of spaces to which we'll add 1 more pixel
|
|
space_add1_nb = space_to_fill - space_add_w * xshaping.nb_can_extend
|
|
line.justified = true
|
|
line.width = line.targeted_width
|
|
pen_x = 0 -- reset alignment
|
|
elseif xshaping.nb_can_extend_fallback > 0 then
|
|
use_can_extend_fallback = true
|
|
space_add_w = math.floor(space_to_fill / xshaping.nb_can_extend_fallback)
|
|
-- nb of spaces to which we'll add 1 more pixel
|
|
space_add1_nb = space_to_fill - space_add_w * xshaping.nb_can_extend_fallback
|
|
line.justified = true
|
|
line.width = line.targeted_width
|
|
pen_x = 0 -- reset alignment
|
|
end
|
|
end
|
|
|
|
line.x_start = pen_x
|
|
local prev_cluster_start_xglyph
|
|
for i, xglyph in ipairs(xshaping) do
|
|
xglyph.x0 = pen_x
|
|
pen_x = pen_x + xglyph.x_advance -- advance from Harfbuzz
|
|
if xglyph.can_extend or (use_can_extend_fallback and xglyph.can_extend_fallback) then
|
|
-- add some pixels for justification
|
|
pen_x = pen_x + space_add_w
|
|
if space_add1_nb > 0 then
|
|
pen_x = pen_x + 1
|
|
space_add1_nb = space_add1_nb - 1
|
|
end
|
|
end
|
|
-- These will be used by _getXYForCharPos() and getCharPosAtXY():
|
|
xglyph.x1 = pen_x
|
|
xglyph.w = xglyph.x1 - xglyph.x0
|
|
-- Because of glyph substitution and merging (one to many, many to one, many to many,
|
|
-- with advance or zero-advance...), glyphs may not always be fine to position
|
|
-- the cursor caret. For X/Y/Charpos positioning/guessing, we'll ignore
|
|
-- glyphs that are not cluster_start, and we build here the full cluster x0/x1/w
|
|
-- by merging them from all glyphs part of this cluster
|
|
if xglyph.is_cluster_start then
|
|
prev_cluster_start_xglyph = xglyph
|
|
else
|
|
if xglyph.x1 > prev_cluster_start_xglyph.x1 then
|
|
prev_cluster_start_xglyph.x1 = xglyph.x1
|
|
prev_cluster_start_xglyph.w = prev_cluster_start_xglyph.x1 - prev_cluster_start_xglyph.x0
|
|
end
|
|
-- We don't update/decrease prev_cluster_start_xglyph.x0, even if one of its glyph
|
|
-- has a backward advance that go back the 1st glyph x0, to not mess positioning.
|
|
end
|
|
if xglyph.is_tab then
|
|
xglyph.no_drawing = true
|
|
-- Note that xglyph.glyph=0 when no glyph was found in any font,
|
|
-- if we ever want to not draw them
|
|
end
|
|
end
|
|
line.x_end = pen_x
|
|
line.xglyphs = xshaping
|
|
-- (Copy para_is_rtl up into 'line', where empty lines without xglyphs have it)
|
|
line.para_is_rtl = line.xglyphs.para_is_rtl
|
|
--- @todo Should we drop these when no more displayed in the page to reclaim memory,
|
|
-- at the expense of recomputing it when back to this page?
|
|
end
|
|
|
|
---- Lays out text.
|
|
function TextBoxWidget:_renderText(start_row_idx, end_row_idx)
|
|
local font_height = self.face.size
|
|
if start_row_idx < 1 then start_row_idx = 1 end
|
|
if end_row_idx > #self.vertical_string_list then end_row_idx = #self.vertical_string_list end
|
|
local row_count = end_row_idx == 0 and 1 or end_row_idx - start_row_idx + 1
|
|
-- We need a bb with the full height (even if we display only a few lines, we
|
|
-- may have to draw an image bigger than these lines)
|
|
local h = self.height or self.line_height_px * row_count
|
|
if self._bb then self._bb:free() end
|
|
local bbtype = nil
|
|
if self.line_num_to_image and self.line_num_to_image[start_row_idx] then
|
|
bbtype = Screen:isColorEnabled() and Blitbuffer.TYPE_BBRGB32 or Blitbuffer.TYPE_BB8
|
|
end
|
|
self._bb = Blitbuffer.new(self.width, h, bbtype)
|
|
self._bb:fill(Blitbuffer.COLOR_WHITE)
|
|
local y = font_height
|
|
|
|
if self.use_xtext then
|
|
for i = start_row_idx, end_row_idx do
|
|
local line = self.vertical_string_list[i]
|
|
if self.line_with_ellipsis and i == self.line_with_ellipsis and not line.ellipsis_added then
|
|
-- Requested to add an ellipsis on this line
|
|
local ellipsis_width = RenderText:getEllipsisWidth(self.face)
|
|
-- no bold: xtext does synthetized bold with normal metrics
|
|
line.width = line.width + ellipsis_width
|
|
if line.width > line.targeted_width then
|
|
-- The ellipsis would overflow: we need to re-makeLine()
|
|
-- this line with a smaller targeted_width
|
|
line = self._xtext:makeLine(line.offset, line.targeted_width - ellipsis_width, false, self._tabstop_width)
|
|
self.vertical_string_list[i] = line -- replace the former one
|
|
end
|
|
if line.end_offset and line.end_offset < #self._xtext then
|
|
-- We'll have shapeLine add the ellipsis to the returned glyphs
|
|
line.end_offset = line.end_offset + 1
|
|
line.idx_to_substitute_with_ellipsis = line.end_offset
|
|
end
|
|
line.ellipsis_added = true -- No need to redo it next time
|
|
end
|
|
self:_shapeLine(line)
|
|
if line.xglyphs then -- non-empty line
|
|
for __, xglyph in ipairs(line.xglyphs) do
|
|
if not xglyph.no_drawing then
|
|
local face = self.face.getFallbackFont(xglyph.font_num) -- callback (not a method)
|
|
local glyph = RenderText:getGlyphByIndex(face, xglyph.glyph, self.bold)
|
|
local color = self.fgcolor
|
|
if self._alt_color_for_rtl then
|
|
color = xglyph.is_rtl and Blitbuffer.COLOR_DARK_GRAY or Blitbuffer.COLOR_BLACK
|
|
end
|
|
self._bb:colorblitFrom(glyph.bb,
|
|
xglyph.x0 + glyph.l + xglyph.x_offset,
|
|
y - glyph.t - xglyph.y_offset,
|
|
0, 0, glyph.bb:getWidth(), glyph.bb:getHeight(), color)
|
|
end
|
|
end
|
|
end
|
|
y = y + self.line_height_px
|
|
end
|
|
-- Render image if any
|
|
self:_renderImage(start_row_idx)
|
|
return
|
|
end
|
|
|
|
-- Only when not self.use_xtext:
|
|
|
|
for i = start_row_idx, end_row_idx do
|
|
local line = self.vertical_string_list[i]
|
|
local pen_x = 0 -- when alignment == "left"
|
|
if self.alignment == "center" then
|
|
pen_x = (self.width - line.width)/2 or 0
|
|
elseif self.alignment == "right" then
|
|
pen_x = (self.width - line.width)
|
|
end
|
|
-- Note: we use kerning=true in all RenderText calls
|
|
-- (But kerning should probably not be used with monospaced fonts.)
|
|
local line_text = self:_getLineText(line)
|
|
if self.line_with_ellipsis and i == self.line_with_ellipsis then
|
|
-- Requested to add an ellipsis on this line
|
|
local ellipsis_width = RenderText:getEllipsisWidth(self.face, self.bold)
|
|
if line.width + ellipsis_width > self.width then
|
|
-- We could try to find the last break point (space, CJK) to
|
|
-- truncate there and add the ellipsis, but well...
|
|
line_text = RenderText:truncateTextByWidth(line_text, self.face, self.width, true, self.bold)
|
|
else
|
|
line_text = line_text .. "…"
|
|
end
|
|
end
|
|
RenderText:renderUtf8Text(self._bb, pen_x, y, self.face, line_text, true, self.bold, self.fgcolor, nil, self:_getLinePads(line))
|
|
y = y + self.line_height_px
|
|
end
|
|
|
|
-- Render image if any
|
|
self:_renderImage(start_row_idx)
|
|
end
|
|
|
|
function TextBoxWidget:_renderImage(start_row_idx)
|
|
local scheduled_update = self.scheduled_update
|
|
self.scheduled_update = nil -- reset it, so we don't have to whenever we return below
|
|
if not self.line_num_to_image or not self.line_num_to_image[start_row_idx] then
|
|
-- No image, no dithering
|
|
if self.dialog then
|
|
self.dialog.dithered = false
|
|
end
|
|
return -- no image on this page
|
|
end
|
|
local image = self.line_num_to_image[start_row_idx]
|
|
local do_schedule_update = false
|
|
local display_bb = false
|
|
local display_alt = false
|
|
local status_text = nil
|
|
local alt_text = image.title or ""
|
|
if image.caption then
|
|
alt_text = alt_text.."\n"..image.caption
|
|
end
|
|
-- Decide what to do/display
|
|
if image.bb then -- we have a bb
|
|
if scheduled_update then -- we're called from a scheduled update
|
|
display_bb = true -- display the bb we got
|
|
else
|
|
-- not from a scheduled update, but update from Tap on image
|
|
-- or we are back to this page from another one
|
|
if self.image_show_alt_text then
|
|
display_alt = true -- display alt_text
|
|
else
|
|
display_bb = true -- display the bb we have
|
|
end
|
|
end
|
|
else -- no bb yet
|
|
display_alt = true -- nothing else to display but alt_text
|
|
if scheduled_update then -- we just failed loading a bb in a scheduled update
|
|
status_text = "⚠" -- show a warning triangle below alt_text
|
|
else
|
|
-- initial display of page (or back on it and previous
|
|
-- load_bb_func failed: try it again)
|
|
if image.load_bb_func then -- we can load a bb
|
|
do_schedule_update = true -- load it and call us again
|
|
status_text = "♲" -- display loading recycle sign below alt_text
|
|
end
|
|
end
|
|
end
|
|
-- logger.dbg("display_bb:", display_bb, "display_alt", display_alt, "status_text:", status_text, "do_schedule_update:", do_schedule_update)
|
|
-- Do what's been decided
|
|
if display_bb then
|
|
-- With alpha-blending if the image contains an alpha channel
|
|
local bbtype = image.bb:getType()
|
|
if bbtype == Blitbuffer.TYPE_BB8A or bbtype == Blitbuffer.TYPE_BBRGB32 then
|
|
-- NOTE: MuPDF feeds us premultiplied alpha (and we don't care w/ GifLib, as alpha is all or nothing).
|
|
if Screen.sw_dithering then
|
|
self._bb:ditherpmulalphablitFrom(image.bb, self.width - image.width, 0)
|
|
else
|
|
self._bb:pmulalphablitFrom(image.bb, self.width - image.width, 0)
|
|
end
|
|
else
|
|
if Screen.sw_dithering then
|
|
self._bb:ditherblitFrom(image.bb, self.width - image.width, 0)
|
|
else
|
|
self._bb:blitFrom(image.bb, self.width - image.width, 0)
|
|
end
|
|
end
|
|
|
|
-- Request dithering
|
|
if self.dialog then
|
|
self.dialog.dithered = true
|
|
end
|
|
end
|
|
local status_height = 0
|
|
if status_text then
|
|
local status_widget = TextWidget:new{
|
|
text = status_text,
|
|
face = Font:getFace("cfont", 20),
|
|
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
|
|
bold = true,
|
|
}
|
|
status_height = status_widget:getSize().h
|
|
status_widget = FrameContainer:new{
|
|
background = Blitbuffer.COLOR_WHITE,
|
|
bordersize = 0,
|
|
margin = 0,
|
|
padding = 0,
|
|
RightContainer:new{
|
|
dimen = {
|
|
w = image.width,
|
|
h = status_height,
|
|
},
|
|
status_widget,
|
|
},
|
|
}
|
|
status_widget:paintTo(self._bb, self.width - image.width, image.height - status_height)
|
|
status_widget:free()
|
|
end
|
|
if display_alt then
|
|
local alt_widget = TextBoxWidget:new{
|
|
text = alt_text,
|
|
face = self.image_alt_face,
|
|
fgcolor = self.image_alt_fgcolor,
|
|
width = image.width,
|
|
-- don't draw over status_text if any
|
|
height = math.max(0, image.height - status_height),
|
|
}
|
|
alt_widget:paintTo(self._bb, self.width - image.width, 0)
|
|
alt_widget:free()
|
|
end
|
|
if do_schedule_update then
|
|
if self.image_update_action then
|
|
-- Cancel any previous one, if we changed page quickly
|
|
UIManager:unschedule(self.image_update_action)
|
|
end
|
|
-- Remember on which page we were launched, so we can
|
|
-- abort if page has changed
|
|
local scheduled_for_linenum = start_row_idx
|
|
self.image_update_action = function()
|
|
self.image_update_action = nil
|
|
if scheduled_for_linenum ~= self.virtual_line_num then
|
|
return -- no more on this page
|
|
end
|
|
local dismissed = image.load_bb_func() -- will update self.bb (or not if failure)
|
|
if dismissed then
|
|
-- If dismissed, the dismiss event may be resent, we
|
|
-- may soon just go display another page. So delay this update a
|
|
-- bit to see if that happened
|
|
UIManager:scheduleIn(0.1, function()
|
|
if scheduled_for_linenum == self.virtual_line_num then
|
|
-- we are still on the same page
|
|
self:update(true)
|
|
UIManager:setDirty(self.dialog or "all", function()
|
|
-- return "ui", self.dimen
|
|
-- We can refresh only the image area, even if we have just
|
|
-- re-rendered the whole textbox as the text has been
|
|
-- rendered just the same as it was
|
|
return "ui", Geom:new{
|
|
x = self.dimen.x + self.width - image.width,
|
|
y = self.dimen.y,
|
|
w = image.width,
|
|
h = image.height,
|
|
},
|
|
true -- Request dithering
|
|
end)
|
|
end
|
|
end)
|
|
else
|
|
-- Image loaded (or not if failure): call us again
|
|
-- with scheduled_update = true so we can draw what we got
|
|
self:update(true)
|
|
UIManager:setDirty(self.dialog or "all", function()
|
|
-- return "ui", self.dimen
|
|
-- We can refresh only the image area, even if we have just
|
|
-- re-rendered the whole textbox as the text has been
|
|
-- rendered just the same as it was
|
|
return "ui", Geom:new{
|
|
x = self.dimen.x + self.width - image.width,
|
|
y = self.dimen.y,
|
|
w = image.width,
|
|
h = image.height,
|
|
},
|
|
true -- Request dithering
|
|
end)
|
|
end
|
|
end
|
|
-- Wrap it with Trapper, as load_bb_func may be using some of its
|
|
-- dismissable methods
|
|
local Trapper = require("ui/trapper")
|
|
UIManager:scheduleIn(0.1, function() Trapper:wrap(self.image_update_action) end)
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:getCharWidth(idx)
|
|
return self.char_width[self.charlist[idx]]
|
|
end
|
|
|
|
function TextBoxWidget:getVisLineCount()
|
|
return self.lines_per_page
|
|
end
|
|
|
|
function TextBoxWidget:getAllLineCount()
|
|
return #self.vertical_string_list
|
|
end
|
|
|
|
function TextBoxWidget:getTextHeight()
|
|
return self.text_height
|
|
end
|
|
|
|
function TextBoxWidget:getLineHeight()
|
|
return self.line_height_px
|
|
end
|
|
|
|
function TextBoxWidget:getVisibleHeightRatios()
|
|
local low = (self.virtual_line_num - 1) / #self.vertical_string_list
|
|
local high = (self.virtual_line_num - 1 + self.lines_per_page) / #self.vertical_string_list
|
|
return low, high
|
|
end
|
|
|
|
-- Helper function to be used before intanstiating a TextBoxWidget instance
|
|
function TextBoxWidget:getFontSizeToFitHeight(height_px, nb_lines, line_height_em)
|
|
-- Get a font size that would fit nb_lines in height_px.
|
|
-- A font with the returned size should then be provided
|
|
-- to TextBoxWidget:new() (as well as the line_height_em given
|
|
-- here, as the line_height= property, if not the default).
|
|
if not nb_lines then
|
|
nb_lines = 1 -- default to 1 line
|
|
end
|
|
if not line_height_em then
|
|
line_height_em = self.line_height -- (TextBoxWidget default above: 0.3)
|
|
end
|
|
-- We do the revert of what's done in :init():
|
|
-- self.line_height_px = Math.round( (1 + self.line_height) * self.face.size )
|
|
local font_size = height_px / nb_lines / (1 + line_height_em)
|
|
font_size = font_size * 1000000 / Screen:scaleBySize(1000000) -- invert scaleBySize
|
|
return math.floor(font_size)
|
|
end
|
|
|
|
function TextBoxWidget:getCharPos()
|
|
-- returns virtual_line_num too
|
|
return self.charpos, self.virtual_line_num
|
|
end
|
|
|
|
function TextBoxWidget:getSize()
|
|
if self.width and self.height then
|
|
return Geom:new{ w = self.width, h = self.height}
|
|
else
|
|
return Geom:new{ w = self.width, h = self._bb:getHeight()}
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:paintTo(bb, x, y)
|
|
self.dimen.x, self.dimen.y = x, y
|
|
bb:blitFrom(self._bb, x, y, 0, 0, self.width, self._bb:getHeight())
|
|
end
|
|
|
|
function TextBoxWidget:onCloseWidget()
|
|
-- Free all resources when UIManager closes this widget
|
|
self:free()
|
|
end
|
|
|
|
function TextBoxWidget:free(full)
|
|
--print("TextBoxWidget:free", full, "on", self)
|
|
-- logger.dbg("TextBoxWidget:free called")
|
|
-- We are called with full=false from other methods here whenever
|
|
-- :_renderText() is to be called to render a new page (when scrolling
|
|
-- inside this text, or moving the view).
|
|
-- Free the between-renderings freeable resources
|
|
if self.image_update_action then
|
|
-- Cancel any scheduled image update, as it is no longer related to current page
|
|
logger.dbg("TextBoxWidget:free: cancelling self.image_update_action")
|
|
UIManager:unschedule(self.image_update_action)
|
|
end
|
|
-- Free blitbuffers
|
|
if self._bb then
|
|
self._bb:free()
|
|
self._bb = nil
|
|
end
|
|
if self.cursor_restore_bb then
|
|
self.cursor_restore_bb:free()
|
|
self.cursor_restore_bb = nil
|
|
end
|
|
if full ~= false then -- final free(): free all remaining resources
|
|
if self.use_xtext and self._xtext then
|
|
-- Allow not waiting until Lua gc() to cleanup C XText malloc'ed stuff
|
|
-- (we should not free it if full=false as it is re-usable across renderings)
|
|
self._xtext:free()
|
|
self._xtext = nil
|
|
-- logger.dbg("TextBoxWidget:_xtext:free()")
|
|
end
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:update(scheduled_update)
|
|
self:free(false)
|
|
-- We set this flag so :_renderText() can know we were called from a
|
|
-- scheduled update and so not schedule another one
|
|
self.scheduled_update = scheduled_update
|
|
self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
|
|
self.scheduled_update = nil
|
|
end
|
|
|
|
function TextBoxWidget:onTapImage(arg, ges)
|
|
if self.line_num_to_image and self.line_num_to_image[self.virtual_line_num] then
|
|
local image = self.line_num_to_image[self.virtual_line_num]
|
|
local tap_x = ges.pos.x - self.dimen.x
|
|
local tap_y = ges.pos.y - self.dimen.y
|
|
-- Check that this tap is on this image
|
|
if tap_x > self.width - image.width and tap_x < self.width and
|
|
tap_y > 0 and tap_y < image.height then
|
|
logger.dbg("tap on image")
|
|
if image.bb then
|
|
-- Toggle between image and alt_text
|
|
self.image_show_alt_text = not self.image_show_alt_text
|
|
self:update()
|
|
UIManager:setDirty(self.dialog or "all", function()
|
|
-- return "ui", self.dimen
|
|
-- We can refresh only the image area, even if we have just
|
|
-- re-rendered the whole textbox as the text has been
|
|
-- rendered just the same as it was
|
|
return "ui", Geom:new{
|
|
x = self.dimen.x + self.width - image.width,
|
|
y = self.dimen.y,
|
|
w = image.width,
|
|
h = image.height,
|
|
},
|
|
not self.image_show_alt_text -- Request dithering when showing the image
|
|
end)
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:scrollDown()
|
|
self.image_show_alt_text = nil -- reset image bb/alt state
|
|
if self.virtual_line_num + self.lines_per_page <= #self.vertical_string_list then
|
|
self:free(false)
|
|
self.virtual_line_num = self.virtual_line_num + self.lines_per_page
|
|
-- If last line shown, set it to be the last line of view
|
|
-- (only if editable, as this would be confusing when reading
|
|
-- a dictionary result or a wikipedia page)
|
|
if self.editable then
|
|
if self.virtual_line_num > #self.vertical_string_list - self.lines_per_page + 1 then
|
|
self.virtual_line_num = #self.vertical_string_list - self.lines_per_page + 1
|
|
if self.virtual_line_num < 1 then
|
|
self.virtual_line_num = 1
|
|
end
|
|
end
|
|
end
|
|
self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
|
|
end
|
|
if self.editable then
|
|
-- move cursor to first line of visible area
|
|
local ln = self.height == nil and 1 or self.virtual_line_num
|
|
self:moveCursorToCharPos(self.vertical_string_list[ln] and self.vertical_string_list[ln].offset or 1)
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:scrollUp()
|
|
self.image_show_alt_text = nil
|
|
if self.virtual_line_num > 1 then
|
|
self:free(false)
|
|
if self.virtual_line_num <= self.lines_per_page then
|
|
self.virtual_line_num = 1
|
|
else
|
|
self.virtual_line_num = self.virtual_line_num - self.lines_per_page
|
|
end
|
|
self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
|
|
end
|
|
if self.editable then
|
|
-- move cursor to first line of visible area
|
|
local ln = self.height == nil and 1 or self.virtual_line_num
|
|
self:moveCursorToCharPos(self.vertical_string_list[ln] and self.vertical_string_list[ln].offset or 1)
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:scrollLines(nb_lines)
|
|
-- nb_lines can be negative
|
|
if nb_lines == 0 then
|
|
return
|
|
end
|
|
self.image_show_alt_text = nil
|
|
local new_line_num = self.virtual_line_num + nb_lines
|
|
if new_line_num < 1 then
|
|
new_line_num = 1
|
|
end
|
|
if new_line_num > #self.vertical_string_list - self.lines_per_page + 1 then
|
|
new_line_num = #self.vertical_string_list - self.lines_per_page + 1
|
|
end
|
|
self.virtual_line_num = new_line_num
|
|
self:free(false)
|
|
self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
|
|
if self.editable then
|
|
local x, y = self:_getXYForCharPos() -- luacheck: no unused
|
|
if y < 0 or y >= self.text_height then
|
|
-- move cursor to first line of visible area
|
|
local ln = self.height == nil and 1 or self.virtual_line_num
|
|
self:moveCursorToCharPos(self.vertical_string_list[ln] and self.vertical_string_list[ln].offset or 1)
|
|
end
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:scrollToTop()
|
|
self.image_show_alt_text = nil
|
|
if self.virtual_line_num > 1 then
|
|
self:free(false)
|
|
self.virtual_line_num = 1
|
|
self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
|
|
end
|
|
if self.editable then
|
|
-- move cursor to first char
|
|
self:moveCursorToCharPos(1)
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:scrollToBottom()
|
|
self.image_show_alt_text = nil
|
|
-- Show last line of text on last line of view
|
|
local ln = #self.vertical_string_list - self.lines_per_page + 1
|
|
if ln < 1 then
|
|
ln = 1
|
|
end
|
|
if self.virtual_line_num ~= ln then
|
|
self:free(false)
|
|
self.virtual_line_num = ln
|
|
self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
|
|
end
|
|
if self.editable then
|
|
-- move cursor to last char
|
|
self:moveCursorToCharPos(#self.charlist + 1)
|
|
end
|
|
end
|
|
|
|
|
|
function TextBoxWidget:scrollToRatio(ratio, force_to_page)
|
|
self.image_show_alt_text = nil
|
|
local line_num
|
|
ratio = math.max(0, math.min(1, ratio)) -- ensure ratio is between 0 and 1 (100%)
|
|
if force_to_page or self.scroll_force_to_page then
|
|
-- We want scroll to align to original pages
|
|
local page_count = 1 + math.floor((#self.vertical_string_list - 1) / self.lines_per_page)
|
|
local page_num = 1 + Math.round((page_count - 1) * ratio)
|
|
line_num = 1 + (page_num - 1) * self.lines_per_page
|
|
else
|
|
-- We want the middle of page to show at ratio, so remove self.lines_per_page/2
|
|
line_num = 1 + math.floor(ratio * #self.vertical_string_list - self.lines_per_page/2)
|
|
if line_num + self.lines_per_page > #self.vertical_string_list then
|
|
line_num = #self.vertical_string_list - self.lines_per_page + 1
|
|
end
|
|
if line_num < 1 then
|
|
line_num = 1
|
|
end
|
|
end
|
|
if line_num ~= self.virtual_line_num then
|
|
self:free(false)
|
|
self.virtual_line_num = line_num
|
|
self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
|
|
end
|
|
if self.editable then
|
|
-- move cursor to first line of visible area
|
|
local ln = self.height == nil and 1 or self.virtual_line_num
|
|
self:moveCursorToCharPos(self.vertical_string_list[ln].offset)
|
|
end
|
|
end
|
|
|
|
|
|
--- Cursor management
|
|
|
|
-- Return the coordinates (relative to current view, so negative y is possible)
|
|
-- of the left of char at charpos (use self.charpos if none provided)
|
|
function TextBoxWidget:_getXYForCharPos(charpos)
|
|
if not charpos then
|
|
charpos = self.charpos
|
|
end
|
|
if self.text == nil or string.len(self.text) == 0 then
|
|
return 0, 0
|
|
end
|
|
-- Find the line number: scan up/down from current virtual_line_num
|
|
local ln = self.height == nil and 1 or self.virtual_line_num
|
|
if charpos > self.vertical_string_list[ln].offset then -- after first line
|
|
while ln < #self.vertical_string_list do
|
|
if self.vertical_string_list[ln + 1].offset > charpos then
|
|
break
|
|
else
|
|
ln = ln + 1
|
|
end
|
|
end
|
|
elseif charpos < self.vertical_string_list[ln].offset then -- before first line
|
|
while ln > 1 do
|
|
ln = ln - 1
|
|
if self.vertical_string_list[ln].offset <= charpos then
|
|
break
|
|
end
|
|
end
|
|
end
|
|
local y = (ln - self.virtual_line_num) * self.line_height_px
|
|
|
|
-- Find the x offset in the current line.
|
|
|
|
if self.use_xtext then
|
|
local line = self.vertical_string_list[ln]
|
|
self:_shapeLine(line)
|
|
local x = line.x_start -- used if empty line (line.x_start = line.x_end)
|
|
if line.xglyphs then -- non-empty line
|
|
-- If charpos is the end of the logical order line, it may not be at end of
|
|
-- visual line (it might be at start, or even in the middle, with bidi!)
|
|
local is_after_last_char = charpos > line.end_offset
|
|
if is_after_last_char then
|
|
-- Find the last char that is really part of this line
|
|
charpos = line.end_offset
|
|
end
|
|
for i, xglyph in ipairs(line.xglyphs) do
|
|
if xglyph.is_cluster_start then -- ignore non-start cluster glyphs
|
|
if charpos >= xglyph.text_index and charpos < xglyph.text_index + xglyph.cluster_len then
|
|
-- Correct glyph found
|
|
if is_after_last_char then
|
|
-- Draw on the right of this glyph if LTR, on the left if RTL
|
|
if xglyph.is_rtl then
|
|
x = xglyph.x0
|
|
else
|
|
x = xglyph.x1
|
|
end
|
|
break
|
|
end
|
|
--- @todo Be more clever with RTL, and at bidi boundaries,
|
|
-- may be depending on line.para_is_rtl and xglyph.bidi_level
|
|
if xglyph.is_rtl then
|
|
x = xglyph.x1 -- draw cursor on the right of this RTL glyph
|
|
else
|
|
x = xglyph.x0
|
|
end
|
|
if xglyph.cluster_len > 1 then
|
|
-- Adjust x so we move the cursor along this single glyph width
|
|
-- depending on charpos position inside this cluster
|
|
local dx = math.floor(xglyph.w * (charpos - xglyph.text_index) / xglyph.cluster_len)
|
|
if xglyph.is_rtl then
|
|
x = x - dx
|
|
else
|
|
x = x + dx
|
|
end
|
|
end
|
|
break
|
|
end
|
|
x = xglyph.x1
|
|
end
|
|
end
|
|
end
|
|
-- logger.dbg("_getXYForCharPos(", charpos, "):", x, y)
|
|
return x, y
|
|
end
|
|
|
|
-- Only when not self.use_xtext:
|
|
|
|
local x = 0
|
|
local offset = self.vertical_string_list[ln].offset
|
|
local nbchars = #self.charlist
|
|
while offset < charpos do
|
|
if offset <= nbchars then -- charpos may exceed #self.charlist
|
|
x = x + self.char_width[self.charlist[offset]] + (self.idx_pad[offset] or 0)
|
|
end
|
|
offset = offset + 1
|
|
end
|
|
-- Cursor can be drawn at x, it will be on the left of the char pointed by charpos
|
|
-- (x=0 for first char of line - for end of line, it will be before the \n, the \n
|
|
-- itself being not displayed)
|
|
return x, y
|
|
end
|
|
|
|
-- Return the charpos at provided coordinates (relative to current view,
|
|
-- so negative y is allowed)
|
|
function TextBoxWidget:getCharPosAtXY(x, y)
|
|
if #self.vertical_string_list == 0 then
|
|
-- if there's no text at all, nothing to do
|
|
return 1
|
|
end
|
|
local ln = self.height == nil and 1 or self.virtual_line_num
|
|
ln = ln + math.floor(y / self.line_height_px)
|
|
if ln < 1 then
|
|
return 1 -- return start of first line
|
|
elseif ln > #self.vertical_string_list then
|
|
return #self.charlist + 1 -- return end of last line
|
|
end
|
|
local idx = self.vertical_string_list[ln].offset
|
|
local end_offset = self.vertical_string_list[ln].end_offset
|
|
if not end_offset then -- empty line
|
|
return idx
|
|
end
|
|
|
|
if self.use_xtext then
|
|
local line = self.vertical_string_list[ln]
|
|
self:_shapeLine(line)
|
|
-- If before start of line or after end of line, no need to loop thru chars
|
|
-- (we return line.end_offset+1 to be after last char)
|
|
if x <= line.x_start then
|
|
if line.para_is_rtl then
|
|
return line.end_offset and line.end_offset + 1 or line.offset
|
|
else
|
|
return line.offset
|
|
end
|
|
elseif x > line.x_end then
|
|
if line.para_is_rtl then
|
|
return line.offset
|
|
else
|
|
return line.end_offset and line.end_offset + 1 or line.offset
|
|
end
|
|
end
|
|
if line.xglyphs then -- non-empty line
|
|
for i, xglyph in ipairs(line.xglyphs) do
|
|
if xglyph.is_cluster_start then -- ignore non-start cluster glyphs
|
|
if x < xglyph.x1 then
|
|
if xglyph.cluster_len <= 1 then
|
|
return xglyph.text_index
|
|
else
|
|
-- Find the most adequate charpos among those in the
|
|
-- cluster by splitting its width into equal parts
|
|
-- for each original char.
|
|
local dw = xglyph.w / xglyph.cluster_len
|
|
for n=1, xglyph.cluster_len do
|
|
if x < xglyph.x0 + n*dw then
|
|
if xglyph.is_rtl then
|
|
return xglyph.text_index + xglyph.cluster_len - n
|
|
else
|
|
return xglyph.text_index + n - 1
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return end_offset + 1 -- should not happen
|
|
end
|
|
|
|
-- Only when not self.use_xtext:
|
|
|
|
if x > self.vertical_string_list[ln].width then -- no need to loop thru chars
|
|
local pos = self.vertical_string_list[ln].end_offset
|
|
if not pos then -- empty last line
|
|
return self.vertical_string_list[ln].offset
|
|
end
|
|
return pos + 1 -- after last char
|
|
end
|
|
local w = 0
|
|
local w_prev
|
|
while idx <= end_offset do
|
|
w_prev = w
|
|
w = w + self.char_width[self.charlist[idx]] + (self.idx_pad[idx] or 0)
|
|
if w > x then -- we're on this char at idx
|
|
if x - w_prev < w - x then -- nearest to char start
|
|
return idx
|
|
else -- nearest to char end: draw cursor before next char
|
|
return idx + 1
|
|
end
|
|
break
|
|
end
|
|
idx = idx + 1
|
|
end
|
|
return end_offset + 1 -- should not happen
|
|
end
|
|
|
|
-- Tunables for the next function: not sure yet which combination is
|
|
-- best to get the less cursor trail - and initially got some crashes
|
|
-- when using refresh funcs. It finally feels fine with both set to true,
|
|
-- but one can turn them to false with a setting to check how some other
|
|
-- combinations do.
|
|
local CURSOR_COMBINE_REGIONS = G_reader_settings:nilOrTrue("ui_cursor_combine_regions")
|
|
local CURSOR_USE_REFRESH_FUNCS = G_reader_settings:nilOrTrue("ui_cursor_use_refresh_funcs")
|
|
|
|
-- Update charpos to the one provided; if out of current view, update
|
|
-- virtual_line_num to move it to view, and draw the cursor
|
|
function TextBoxWidget:moveCursorToCharPos(charpos)
|
|
if not self.editable then
|
|
-- we shouldn't have been called if not editable
|
|
logger.warn("TextBoxWidget:moveCursorToCharPos called, but not editable")
|
|
return
|
|
end
|
|
self.charpos = charpos
|
|
self.prev_virtual_line_num = self.virtual_line_num
|
|
local x, y = self:_getXYForCharPos() -- we can get y outside current view
|
|
-- adjust self.virtual_line_num for overflowed y to have y in current view
|
|
if y < 0 then
|
|
local scroll_lines = math.ceil( -y / self.line_height_px )
|
|
self.virtual_line_num = self.virtual_line_num - scroll_lines
|
|
if self.virtual_line_num < 1 then
|
|
self.virtual_line_num = 1
|
|
end
|
|
y = y + scroll_lines * self.line_height_px
|
|
end
|
|
if y >= self.text_height then
|
|
local scroll_lines = math.floor( (y-self.text_height) / self.line_height_px ) + 1
|
|
self.virtual_line_num = self.virtual_line_num + scroll_lines
|
|
-- needs to deal with possible overflow ?
|
|
y = y - scroll_lines * self.line_height_px
|
|
end
|
|
-- We can also get x ouside current view, when a line takes the full width
|
|
-- (which happens when text is justified): move the cursor a bit to the left
|
|
-- (it will be drawn over the right of the last glyph, which should be ok.)
|
|
if x > self.width - self.cursor_line.dimen.w then
|
|
x = self.width - self.cursor_line.dimen.w
|
|
end
|
|
if self.for_measurement_only then
|
|
return -- we're a dummy widget used for computing text height, don't render/refresh anything
|
|
end
|
|
if not self._bb then
|
|
return -- no bb yet to render the cursor too
|
|
end
|
|
if self.virtual_line_num ~= self.prev_virtual_line_num then
|
|
-- We scrolled the view: full render and refresh needed
|
|
self:free(false)
|
|
self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
|
|
-- Store the original image of where we will draw the cursor, for a
|
|
-- quick restore and two small refreshes when moving only the cursor
|
|
self.cursor_restore_x = x
|
|
self.cursor_restore_y = y
|
|
self.cursor_restore_bb = Blitbuffer.new(self.cursor_line.dimen.w, self.cursor_line.dimen.h, self._bb:getType())
|
|
self.cursor_restore_bb:blitFrom(self._bb, 0, 0, x, y, self.cursor_line.dimen.w, self.cursor_line.dimen.h)
|
|
-- Paint the cursor, and refresh the whole widget
|
|
self.cursor_line:paintTo(self._bb, x, y)
|
|
UIManager:setDirty(self.dialog or "all", function()
|
|
return "ui", self.dimen
|
|
end)
|
|
elseif self._bb then
|
|
if CURSOR_USE_REFRESH_FUNCS then
|
|
-- We didn't scroll the view, only the cursor was moved
|
|
local restore_x, restore_y
|
|
if self.cursor_restore_bb then
|
|
-- Restore the previous cursor position content, and do
|
|
-- a small ui refresh of the old cursor area
|
|
self._bb:blitFrom(self.cursor_restore_bb, self.cursor_restore_x, self.cursor_restore_y,
|
|
0, 0, self.cursor_line.dimen.w, self.cursor_line.dimen.h)
|
|
-- remember current values for use in the setDirty funcs, as
|
|
-- we will have overriden them when these are called
|
|
restore_x = self.cursor_restore_x
|
|
restore_y = self.cursor_restore_y
|
|
if not CURSOR_COMBINE_REGIONS then
|
|
UIManager:setDirty(self.dialog or "all", function()
|
|
return "ui", Geom:new{
|
|
x = self.dimen.x + restore_x,
|
|
y = self.dimen.y + restore_y,
|
|
w = self.cursor_line.dimen.w,
|
|
h = self.cursor_line.dimen.h,
|
|
}
|
|
end)
|
|
end
|
|
self.cursor_restore_bb:free()
|
|
self.cursor_restore_bb = nil
|
|
end
|
|
-- Store the original image of where we will draw the new cursor
|
|
self.cursor_restore_x = x
|
|
self.cursor_restore_y = y
|
|
self.cursor_restore_bb = Blitbuffer.new(self.cursor_line.dimen.w, self.cursor_line.dimen.h, self._bb:getType())
|
|
self.cursor_restore_bb:blitFrom(self._bb, 0, 0, x, y, self.cursor_line.dimen.w, self.cursor_line.dimen.h)
|
|
-- Paint the cursor, and do a small ui refresh of the new cursor area
|
|
self.cursor_line:paintTo(self._bb, x, y)
|
|
UIManager:setDirty(self.dialog or "all", function()
|
|
local cursor_region = Geom:new{
|
|
x = self.dimen.x + x,
|
|
y = self.dimen.y + y,
|
|
w = self.cursor_line.dimen.w,
|
|
h = self.cursor_line.dimen.h,
|
|
}
|
|
if CURSOR_COMBINE_REGIONS and restore_x and restore_y then
|
|
local restore_region = Geom:new{
|
|
x = self.dimen.x + restore_x,
|
|
y = self.dimen.y + restore_y,
|
|
w = self.cursor_line.dimen.w,
|
|
h = self.cursor_line.dimen.h,
|
|
}
|
|
cursor_region = cursor_region:combine(restore_region)
|
|
end
|
|
return "ui", cursor_region
|
|
end)
|
|
else -- CURSOR_USE_REFRESH_FUNCS = false
|
|
-- We didn't scroll the view, only the cursor was moved
|
|
local restore_region
|
|
if self.cursor_restore_bb then
|
|
-- Restore the previous cursor position content, and do
|
|
-- a small ui refresh of the old cursor area
|
|
self._bb:blitFrom(self.cursor_restore_bb, self.cursor_restore_x, self.cursor_restore_y,
|
|
0, 0, self.cursor_line.dimen.w, self.cursor_line.dimen.h)
|
|
if self.dimen then
|
|
restore_region = Geom:new{
|
|
x = self.dimen.x + self.cursor_restore_x,
|
|
y = self.dimen.y + self.cursor_restore_y,
|
|
w = self.cursor_line.dimen.w,
|
|
h = self.cursor_line.dimen.h,
|
|
}
|
|
if not CURSOR_COMBINE_REGIONS then
|
|
UIManager:setDirty(self.dialog or "all", "ui", restore_region)
|
|
end
|
|
end
|
|
self.cursor_restore_bb:free()
|
|
self.cursor_restore_bb = nil
|
|
end
|
|
-- Store the original image of where we will draw the new cursor
|
|
self.cursor_restore_x = x
|
|
self.cursor_restore_y = y
|
|
self.cursor_restore_bb = Blitbuffer.new(self.cursor_line.dimen.w, self.cursor_line.dimen.h, self._bb:getType())
|
|
self.cursor_restore_bb:blitFrom(self._bb, 0, 0, x, y, self.cursor_line.dimen.w, self.cursor_line.dimen.h)
|
|
-- Paint the cursor, and do a small ui refresh of the new cursor area
|
|
self.cursor_line:paintTo(self._bb, x, y)
|
|
if self.dimen then
|
|
local cursor_region = Geom:new{
|
|
x = self.dimen.x + x,
|
|
y = self.dimen.y + y,
|
|
w = self.cursor_line.dimen.w,
|
|
h = self.cursor_line.dimen.h,
|
|
}
|
|
if CURSOR_COMBINE_REGIONS and restore_region then
|
|
cursor_region = cursor_region:combine(restore_region)
|
|
end
|
|
UIManager:setDirty(self.dialog or "all", "ui", cursor_region)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:moveCursorToXY(x, y, restrict_to_view)
|
|
if restrict_to_view then
|
|
-- Wrap y to current view (when getting coordinates from gesture)
|
|
-- (no real need to check for x, getCharPosAtXY() is ok with any x)
|
|
if y < 0 then
|
|
y = 0
|
|
end
|
|
if y >= self.text_height then
|
|
y = self.text_height - 1
|
|
end
|
|
end
|
|
local charpos = self:getCharPosAtXY(x, y)
|
|
self:moveCursorToCharPos(charpos)
|
|
end
|
|
|
|
-- Update self.virtual_line_num to the page containing charpos
|
|
function TextBoxWidget:scrollViewToCharPos()
|
|
if self.top_line_num then
|
|
-- if previous top_line_num provided, go to that line
|
|
self.virtual_line_num = self.top_line_num
|
|
if self.virtual_line_num < 1 then
|
|
self.virtual_line_num = 1
|
|
end
|
|
if self.virtual_line_num > #self.vertical_string_list then
|
|
self.virtual_line_num = #self.vertical_string_list
|
|
end
|
|
-- Ensure we don't show too much blank at end (when deleting last lines)
|
|
-- local max_empty_lines = math.floor(self.lines_per_page / 2)
|
|
-- Best to not allow any, for initially non-scrolled widgets
|
|
local max_empty_lines = 0
|
|
local max_virtual_line_num = #self.vertical_string_list - self.lines_per_page + 1 + max_empty_lines
|
|
if self.virtual_line_num > max_virtual_line_num then
|
|
self.virtual_line_num = max_virtual_line_num
|
|
if self.virtual_line_num < 1 then
|
|
self.virtual_line_num = 1
|
|
end
|
|
end
|
|
-- and adjust if cursor is out of view
|
|
self:moveCursorToCharPos(self.charpos)
|
|
return
|
|
end
|
|
-- Otherwise, find the "hard" page containing charpos
|
|
local ln = 1
|
|
while true do
|
|
local lend = ln + self.lines_per_page - 1
|
|
if lend >= #self.vertical_string_list then
|
|
break -- last page
|
|
end
|
|
if self.vertical_string_list[lend+1].offset >= self.charpos then
|
|
break
|
|
end
|
|
ln = ln + self.lines_per_page
|
|
end
|
|
self.virtual_line_num = ln
|
|
end
|
|
|
|
function TextBoxWidget:moveCursorLeft()
|
|
if self.charpos > 1 then
|
|
self:moveCursorToCharPos(self.charpos-1)
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:moveCursorRight()
|
|
if self.charpos < #self.charlist + 1 then -- we can move after last char
|
|
self:moveCursorToCharPos(self.charpos+1)
|
|
end
|
|
end
|
|
|
|
function TextBoxWidget:moveCursorUp()
|
|
if self.vertical_string_list and #self.vertical_string_list < 2 then return end
|
|
local x, y = self:_getXYForCharPos()
|
|
self:moveCursorToXY(x, y - self.line_height_px)
|
|
end
|
|
|
|
function TextBoxWidget:moveCursorDown()
|
|
if self.vertical_string_list and #self.vertical_string_list < 2 then return end
|
|
local x, y = self:_getXYForCharPos()
|
|
self:moveCursorToXY(x, y + self.line_height_px)
|
|
end
|
|
|
|
|
|
--- Text selection with Hold
|
|
|
|
-- Allow selection of a single word at hold position
|
|
function TextBoxWidget:onHoldWord(callback, ges)
|
|
if not callback then return end
|
|
|
|
local x, y = ges.pos.x - self.dimen.x, ges.pos.y - self.dimen.y
|
|
local line_num = math.ceil(y / self.line_height_px) + self.virtual_line_num-1
|
|
local line = self.vertical_string_list[line_num]
|
|
logger.dbg("holding on line", line)
|
|
if line then
|
|
local char_start = line.offset
|
|
local char_end -- char_end is non-inclusive
|
|
if line_num >= #self.vertical_string_list then
|
|
char_end = #self.charlist + 1
|
|
else
|
|
char_end = self.vertical_string_list[line_num+1].offset
|
|
end
|
|
local char_probe_x = 0
|
|
local idx = char_start
|
|
-- find which character the touch is holding
|
|
while idx < char_end do
|
|
--- @fixme This might break if kerning is enabled.
|
|
char_probe_x = char_probe_x + self.char_width[self.charlist[idx]] + (self.idx_pad[idx] or 0)
|
|
if char_probe_x > x then
|
|
-- ignore spaces
|
|
if self.charlist[idx] == " " then break end
|
|
-- now find which word the character is in
|
|
local words = util.splitToWords(self:_getLineText(line))
|
|
local probe_idx = char_start
|
|
for _, w in ipairs(words) do
|
|
-- +1 for word separtor
|
|
probe_idx = probe_idx + #util.splitToChars(w)
|
|
if idx <= probe_idx - 1 then
|
|
callback(w)
|
|
return
|
|
end
|
|
end
|
|
break
|
|
end
|
|
idx = idx + 1
|
|
end
|
|
end
|
|
|
|
return
|
|
end
|
|
|
|
-- Allow selection of one or more words (with no visual feedback)
|
|
-- Gestures should be declared in widget using us (e.g dictquicklookup.lua)
|
|
|
|
-- Constants for which side of a word to find
|
|
local FIND_START = 1
|
|
local FIND_END = 2
|
|
|
|
function TextBoxWidget:onHoldStartText(_, ges)
|
|
-- store hold start position and timestamp, will be used on release
|
|
self.hold_start_x = ges.pos.x - self.dimen.x
|
|
self.hold_start_y = ges.pos.y - self.dimen.y
|
|
|
|
-- check coordinates are actually inside our area
|
|
if self.hold_start_x < 0 or self.hold_start_x > self.dimen.w or
|
|
self.hold_start_y < 0 or self.hold_start_y > self.dimen.h then
|
|
self.hold_start_tv = nil -- don't process coming HoldRelease event
|
|
return false -- let event be processed by other widgets
|
|
end
|
|
|
|
self.hold_start_tv = UIManager:getTime()
|
|
return true
|
|
end
|
|
|
|
function TextBoxWidget:onHoldPanText(_, ges)
|
|
-- We don't highlight the currently selected text, but just let this
|
|
-- event pop up if we are not currently selecting text
|
|
if not self.hold_start_tv then
|
|
return false
|
|
end
|
|
-- Don't let that event be processed by other widget
|
|
return true
|
|
end
|
|
|
|
function TextBoxWidget:onHoldReleaseText(callback, ges)
|
|
if not callback then return end
|
|
|
|
local hold_end_x = ges.pos.x - self.dimen.x
|
|
local hold_end_y = ges.pos.y - self.dimen.y
|
|
|
|
-- check we have seen a HoldStart event
|
|
if not self.hold_start_tv then
|
|
return false
|
|
end
|
|
-- check start and end coordinates are actually inside our area
|
|
if self.hold_start_x < 0 or hold_end_x < 0 or
|
|
self.hold_start_x > self.dimen.w or hold_end_x > self.dimen.w or
|
|
self.hold_start_y < 0 or hold_end_y < 0 or
|
|
self.hold_start_y > self.dimen.h or hold_end_y > self.dimen.h then
|
|
return false
|
|
end
|
|
|
|
local hold_duration = UIManager:getTime() - self.hold_start_tv
|
|
|
|
-- If page contains an image, check if Hold is on this image and deal
|
|
-- with it directly
|
|
if self.line_num_to_image and self.line_num_to_image[self.virtual_line_num] then
|
|
local image = self.line_num_to_image[self.virtual_line_num]
|
|
if hold_end_x > self.width - image.width and hold_end_y < image.height then
|
|
-- Only if low-res image is loaded, so we have something to display
|
|
-- if high-res loading is not implemented or if its loading fails
|
|
if image.bb then
|
|
logger.dbg("hold on image")
|
|
local load_and_show_image = function()
|
|
if not image.hi_bb and image.load_bb_func then
|
|
image.load_bb_func(true) -- load high res image if implemented
|
|
end
|
|
-- display hi_bb, or low-res bb if hi_bb has not been
|
|
-- made (if not implemented, or failed, or dismissed)
|
|
local ImageViewer = require("ui/widget/imageviewer")
|
|
local imgviewer = ImageViewer:new{
|
|
image = image.hi_bb or image.bb, -- fallback to low-res if high-res failed
|
|
image_disposable = false, -- we may re-use our bb if called again
|
|
with_title_bar = true,
|
|
title_text = image.title,
|
|
caption = image.caption,
|
|
fullscreen = true,
|
|
}
|
|
UIManager:show(imgviewer)
|
|
end
|
|
-- Wrap it with Trapper, as load_bb_func may be using some of its
|
|
-- dismissable methods
|
|
local Trapper = require("ui/trapper")
|
|
UIManager:scheduleIn(0.1, function() Trapper:wrap(load_and_show_image) end)
|
|
-- And we return without calling the "Hold on text" callback
|
|
return true
|
|
end
|
|
end
|
|
end
|
|
-- Swap start and end if needed
|
|
local x0, y0, x1, y1
|
|
-- first, sort by y/line_num
|
|
local start_line_num = math.ceil(self.hold_start_y / self.line_height_px)
|
|
local end_line_num = math.ceil(hold_end_y / self.line_height_px)
|
|
if start_line_num < end_line_num then
|
|
x0, y0 = self.hold_start_x, self.hold_start_y
|
|
x1, y1 = hold_end_x, hold_end_y
|
|
elseif start_line_num > end_line_num then
|
|
x0, y0 = hold_end_x, hold_end_y
|
|
x1, y1 = self.hold_start_x, self.hold_start_y
|
|
else -- same line_num : sort by x
|
|
if self.hold_start_x <= hold_end_x then
|
|
x0, y0 = self.hold_start_x, self.hold_start_y
|
|
x1, y1 = hold_end_x, hold_end_y
|
|
else
|
|
x0, y0 = hold_end_x, hold_end_y
|
|
x1, y1 = self.hold_start_x, self.hold_start_y
|
|
end
|
|
end
|
|
|
|
-- Reset start infos, so we do not reuse them and can catch
|
|
-- a missed start event
|
|
self.hold_start_x = nil
|
|
self.hold_start_y = nil
|
|
self.hold_start_tv = nil
|
|
|
|
if self.use_xtext then
|
|
-- With xtext and fribidi, words may not be laid out in logical order,
|
|
-- so the left of a visual word may be its end in logical order,
|
|
-- and the right its start.
|
|
-- So, just find out charpos (text indice) of both points and
|
|
-- find word edges in the logical order text/charlist.
|
|
local sel_start_idx = self:getCharPosAtXY(x0, y0)
|
|
local sel_end_idx = self:getCharPosAtXY(x1, y1)
|
|
if not sel_start_idx or not sel_end_idx then
|
|
-- one or both hold points were out of text
|
|
return true
|
|
end
|
|
if sel_start_idx > sel_end_idx then -- re-order if needed
|
|
sel_start_idx, sel_end_idx = sel_end_idx, sel_start_idx
|
|
end
|
|
-- We get cursor positions, which can be after last char,
|
|
-- and that we need to correct. But if both positions are
|
|
-- after last char, the full selection is out of text.
|
|
if sel_start_idx > #self._xtext then -- Both are after last char
|
|
return true
|
|
end
|
|
if sel_end_idx > #self._xtext then -- Only end is after last char
|
|
sel_end_idx = #self._xtext
|
|
end
|
|
-- Delegate word boundaries search to xtext.cpp, which can
|
|
-- use libunibreak's wordbreak features.
|
|
-- (50 is the nb of chars backward and ahead of selection indices
|
|
-- to consider when looking for word boundaries)
|
|
local selected_text = self._xtext:getSelectedWords(sel_start_idx, sel_end_idx, 50)
|
|
|
|
logger.dbg("onHoldReleaseText (duration:", hold_duration:tonumber(), ") :",
|
|
sel_start_idx, ">", sel_end_idx, "=", selected_text)
|
|
callback(selected_text, hold_duration)
|
|
return true
|
|
end
|
|
|
|
-- Only when not self.use_xtext:
|
|
|
|
-- similar code to find start or end is in _findWordEdge() helper
|
|
local sel_start_idx = self:_findWordEdge(x0, y0, FIND_START)
|
|
local sel_end_idx = self:_findWordEdge(x1, y1, FIND_END)
|
|
|
|
if not sel_start_idx or not sel_end_idx then
|
|
-- one or both hold points were out of text
|
|
return true
|
|
end
|
|
|
|
local selected_text = table.concat(self.charlist, "", sel_start_idx, sel_end_idx)
|
|
logger.dbg("onHoldReleaseText (duration:", hold_duration:tonumber(), ") :", sel_start_idx, ">", sel_end_idx, "=", selected_text)
|
|
callback(selected_text, hold_duration)
|
|
return true
|
|
end
|
|
|
|
function TextBoxWidget:_findWordEdge(x, y, side)
|
|
if side ~= FIND_START and side ~= FIND_END then
|
|
return
|
|
end
|
|
local line_num = math.ceil(y / self.line_height_px) + self.virtual_line_num-1
|
|
local line = self.vertical_string_list[line_num]
|
|
if not line then
|
|
return -- below last line : no selection
|
|
end
|
|
local char_start = line.offset
|
|
local char_end -- char_end is non-inclusive
|
|
if line_num >= #self.vertical_string_list then
|
|
char_end = #self.charlist + 1
|
|
else
|
|
char_end = self.vertical_string_list[line_num+1].offset
|
|
end
|
|
local char_probe_x = 0
|
|
local idx = char_start
|
|
local edge_idx = nil
|
|
-- find which character the touch is holding
|
|
while idx < char_end do
|
|
char_probe_x = char_probe_x + self.char_width[self.charlist[idx]] + (self.idx_pad[idx] or 0)
|
|
if char_probe_x > x then
|
|
-- character found, find which word the character is in, and
|
|
-- get its start/end idx
|
|
local words = util.splitToWords(self:_getLineText(line))
|
|
-- words may contain separators (space, punctuation) : we don't
|
|
-- discriminate here, it's the caller job to clean what was
|
|
-- selected
|
|
local probe_idx = char_start
|
|
local next_probe_idx
|
|
for _, w in ipairs(words) do
|
|
next_probe_idx = probe_idx + #util.splitToChars(w)
|
|
if idx < next_probe_idx then
|
|
if side == FIND_START then
|
|
edge_idx = probe_idx
|
|
elseif side == FIND_END then
|
|
edge_idx = next_probe_idx - 1
|
|
end
|
|
break
|
|
end
|
|
probe_idx = next_probe_idx
|
|
end
|
|
if edge_idx then
|
|
break
|
|
end
|
|
end
|
|
idx = idx + 1
|
|
end
|
|
return edge_idx
|
|
end
|
|
|
|
return TextBoxWidget
|