2
0
mirror of https://github.com/koreader/koreader synced 2024-11-11 19:11:14 +00:00
koreader/frontend/ui/widget/textboxwidget.lua
NiLuJe 6d53f83286
The great Input/GestureDetector/TimeVal spring cleanup (a.k.a., a saner main loop) (#7415)
* 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..
2021-03-30 02:57:59 +02:00

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