--[[--
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 dbg = require("dbg")
local time = require("ui/time")
local util = require("util")
local xtext -- Delayed (and optional) loading
local Screen = require("device").screen

local TextBoxWidget = InputContainer:extend{
    text = nil,
    editable = false, -- Editable flag for whether drawing the cursor or not.
    select_mode = nil, -- select mode in InputText, wider cursor line
    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 synthesized if not available),
                  -- or bold=Font.FORCE_SYNTHETIZED_BOLD to force using synthesized 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,
    bgcolor = Blitbuffer.COLOR_WHITE,
    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
    current_line_num = 1, -- index of the currently edited line (i.e., where our cursor is)
    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,

    highlight_text_selection = false, -- if true then the selected text will be highlighted
    highlight_rects = nil,
    highlight_start_idx = nil,
    highlight_end_idx = nil,
    highlight_start_line = nil,
    highlight_end_line = nil,
    highlight_clear_and_redraw_action = nil,
    hold_start_pos = nil,
    hold_end_pos = 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 alignment 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

    -- Some Poor Text Formatting (PTF, not not to be confused with Richer RTF :)) can be done
    -- by embedding some chars in self.text (these codepoints are not part of Unicode, so
    -- hopefully not present naturally in any provided self.text).
    PTF_HEADER = "\u{FFF1}", -- should be put at start of 'text' to indicate we may find other PTF_ chars
    PTF_BOLD_START = "\u{FFF2}", -- start a sequence of bold chars
    PTF_BOLD_END = "\u{FFF3}",   -- end a sequence of bold chars
    _ptf_char_is_bold = nil,
        -- This uses fake/synthetic bold, making each glyph bolder without modifying advance.
        -- Some other possible formatting we could implement is different alignment (center,
        -- right) of some lines in the provided text.
}

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 )
    -- Get accurate initial baseline and possible height overflow (so our bb
    -- is tall enough to draw glyphs with descender larger than line height)
    local face_height, face_ascender = self.face.ftsize:getHeightAndAscender()
    local line_heights_diff = math.floor(self.line_height_px - face_height)
    if line_heights_diff >= 0 then
        -- Glyphs will fit in our line_height_px: adjust baseline.
        self.line_glyph_baseline = math.floor(face_ascender + line_heights_diff/2)
        self.line_glyph_extra_height = 0
    else
        -- Glyphs may be taller than our line_height_px
        self.line_glyph_baseline = math.floor(face_ascender)
        self.line_glyph_extra_height = -line_heights_diff
    end

    self.highlight_lighten_factor = G_reader_settings:readSetting("highlight_lighten_factor", 0.2)
    self.cursor_line = LineWidget:new{
        dimen = Geom:new{
            w = self.select_mode and 3*Size.line.medium or 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

    -- Check for Poor Text Formatting
    if not self.charlist and self.text and type(self.text) == "string"
            and self.text:sub(1, #TextBoxWidget.PTF_HEADER) == TextBoxWidget.PTF_HEADER then
        -- Support for very simple text styling (bold only for now)
        self._ptf_char_is_bold = {}
        -- Alas, we can't let any of our flag characters be fed to xtext (even with ASCII control
        -- chars, it would give them a width, which would result at best in spurious added spacing).
        -- So, split text into a table of chars, filter our flags out keeping track of where they
        -- start and end bold, and rebuild self.text without them.
        local charlist = util.splitToChars(self.text)
        table.remove(charlist, 1)
        local is_bold = false
        local len = #charlist
        local i = 1
        while i <= len do
            local ch = charlist[i]
            if ch == TextBoxWidget.PTF_BOLD_START then
                is_bold = true
                table.remove(charlist, i)
                len = len - 1
            elseif ch == TextBoxWidget.PTF_BOLD_END then
                is_bold = false
                table.remove(charlist, i)
                len = len - 1
            else
                if is_bold then
                    self._ptf_char_is_bold[i] = true
                end
                i = i + 1
            end
        end
        self.text = table.concat(charlist, "")
        charlist = nil -- luacheck: no unused
    end

    self:_computeTextDimensions()
    self:_updateLayout()
    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:_computeTextDimensions()
    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
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
        if not self.text then
            self.text = ""
        elseif type(self.text) ~= "string" then
            self.text = tostring(self.text)
        end
        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
        xtext = 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 split 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 represent 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 split 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 split 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)
    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
    h = h + self.line_glyph_extra_height
    if self._bb then self._bb:free() end
    local bbtype = nil
    local color_fg = not Blitbuffer.isColor8(self.fgcolor)
    local color_bg = not Blitbuffer.isColor8(self.bgcolor)
    -- Color, whether from images or fg or bg, means we'll need an RGB32 buffer (if it makes sense, e.g., on a color screen).
    if (self.line_num_to_image and self.line_num_to_image[start_row_idx]) or color_fg or color_bg then
        bbtype = Screen:isColorEnabled() and Blitbuffer.TYPE_BBRGB32 or Blitbuffer.TYPE_BB8
    end
    self._bb = Blitbuffer.new(self.width, h, bbtype)
    if not color_bg then
        self._bb:fill(self.bgcolor)
    else
        self._bb:paintRectRGB32(0, 0, self._bb:getWidth(), self._bb:getHeight(), self.bgcolor)
    end

    local y = self.line_glyph_baseline
    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 synthesized 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 bolder = self._ptf_char_is_bold and self._ptf_char_is_bold[xglyph.text_index] or false
                        local glyph = RenderText:getGlyphByIndex(face, xglyph.glyph, self.bold, bolder)
                        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
                        if not color_fg then
                            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)
                        else
                            self._bb:colorblitFromRGB32(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
            end
            y = y + self.line_height_px
        end
        -- Render image if any
        self:_renderImage(start_row_idx)

        if self.highlight_rects then
            for _, rect in ipairs(self.highlight_rects) do
                self._bb:darkenRect(rect.x, rect.y, rect.w, rect.h, self.highlight_lighten_factor)
            end
        end

        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)

    if self.highlight_rects then
        for _, rect in ipairs(self.highlight_rects) do
            self._bb:darkenRect(rect.x, rect.y, rect.w, rect.h, self.highlight_lighten_factor)
        end
    end
end

-- Lay out the full text, starting at the current line.
-- (NOTE: This instantiates the inner bb (self._bb), so be careful about its lifecycle when you call this,
--        c.f., TextBoxWidget:update).
function TextBoxWidget:_updateLayout(update_highlight)
    if self.highlight_text_selection and (update_highlight == nil or update_highlight) then
        self:updateHighlight()
    end
    self:_renderText(self.virtual_line_num, self.virtual_line_num + self.lines_per_page - 1)
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 = Geom:new{
                    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:getBaseline()
    return self.line_glyph_baseline
end

function TextBoxWidget:getVisibleHeightRatios()
    if #self.vertical_string_list == 0 then
        return 0, 1
    end
    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, font_face, font_bold)
    -- 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 face_size = height_px / nb_lines / (1 + line_height_em)
    local font_size = math.floor(face_size * 1000000 / Screen:scaleBySize(1000000)) -- invert scaleBySize
    if Screen:scaleBySize(font_size) > face_size then -- be really sure we won't get it larger
        font_size = font_size - 1
    end
    -- Because of self.line_glyph_extra_height added to the final bb, the font_size we got
    -- up to here can still generate a TextBoxWidget taller than the provided height_px,
    -- which might be good enough for some usages.
    -- If better accuracy is needed, provide (..., font_face, font_bold) so we can return
    -- a better font_size for the font that will be used.
    if font_face then
        while true do
            -- As done in TextBoxWidget:init():
            local line_height_px = Math.round( (1 + line_height_em) * Screen:scaleBySize(font_size) )
            local face = Font:getFace(font_face, font_size)
            face = Font:getAdjustedFace(face, font_bold)
            local face_height = face.ftsize:getHeightAndAscender()
            local line_heights_diff = math.floor(line_height_px - face_height)
            if line_heights_diff >= 0 then
                break
            end
            local line_glyph_extra_height = -line_heights_diff
            if line_height_px * nb_lines + line_glyph_extra_height <= height_px then
                break
            end
            if font_size <= 1 then
                break
            end
            font_size = font_size - 1
        end
    end
    return font_size
end

function TextBoxWidget:getCharPos()
    -- returns virtual_line_num & current_line_num too
    return self.charpos, self.virtual_line_num, self.current_line_num
end

function TextBoxWidget:getSize()
    -- Make sure we actually have a BB, in case we're recycling an instance... (c.f., #8241)
    if not self._bb then
        self:_updateLayout()
    end

    return Geom:new{x = 0, y = 0, w = self.width, h = self._bb:getHeight()}
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 reusable across renderings)
            self._xtext:free()
            self._xtext = nil
            -- logger.dbg("TextBoxWidget:_xtext:free()")
        end

        -- c.f., :_splitToLines
        self.vertical_string_list = {}
        self.line_num_to_image = nil
    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:_updateLayout()
    self.scheduled_update = nil
end

function TextBoxWidget:setText(text)
    if text == self.text then
        return
    end

    self.text = text
    self:_computeTextDimensions()
    self:update()

    -- Don't break the reference
    local new_size = self:getSize()
    self.dimen.w = new_size.w
    self.dimen.h = new_size.h
end
dbg:guard(TextBoxWidget, "setText",
    function(self, text)
        assert(type(text) == "string",
            "Wrong text type (expected string)")
    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:_updateLayout()
    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:_updateLayout()
    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:_updateLayout()
    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:_updateLayout()
    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:_updateLayout()
    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:_updateLayout()
    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)
-- and the number of the line with charpos on the screen
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, 1
    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
    local screen_line_num = ln - self.virtual_line_num + 1

    -- 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, screen_line_num
    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, screen_line_num
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)
    self.charpos = charpos
    self.prev_virtual_line_num = self.virtual_line_num
    local x, y, screen_line_num = self:_getXYForCharPos() -- we can get y outside current view
    self.current_line_num = screen_line_num + self.virtual_line_num - 1
    -- 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 outside 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:_updateLayout()
        -- 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 overridden 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

-- Update view to show the line with charpos not far than <centered_lines_count> lines away
-- from the center of the screen, and draw the cursor.
function TextBoxWidget:moveCursorToCharPosKeepingViewCentered(charpos, centered_lines_count)
    local old_virtual_line_num = self.virtual_line_num
    self.for_measurement_only = true
    self:moveCursorToCharPos(charpos)
    self.for_measurement_only = false
    local _, _, screen_line_num = self:_getXYForCharPos(charpos)
    local new_virtual_line_num = self.virtual_line_num + screen_line_num - math.floor(self.lines_per_page / 2)
    local max_virtual_line_num = #self.vertical_string_list - self.lines_per_page + 1
    if new_virtual_line_num < 1 then
        new_virtual_line_num = 1
    elseif new_virtual_line_num > max_virtual_line_num then
        new_virtual_line_num = max_virtual_line_num
    end
    if math.abs(new_virtual_line_num - old_virtual_line_num) > centered_lines_count then
        self.virtual_line_num = new_virtual_line_num
    else
        self.virtual_line_num = old_virtual_line_num
    end
    self:_updateLayout()
    self:moveCursorToCharPos(charpos)
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()
    local x, y = self:_getXYForCharPos()
    self:moveCursorToXY(x, y - self.line_height_px)
end

function TextBoxWidget:moveCursorDown()
    local x, y = self:_getXYForCharPos()
    self:moveCursorToXY(x, y + self.line_height_px)
end

function TextBoxWidget:moveCursorHome()
    self:moveCursorToCharPos(self.vertical_string_list[self.current_line_num] and self.vertical_string_list[self.current_line_num].offset or 1)
end

function TextBoxWidget:moveCursorEnd()
    self:moveCursorToCharPos(self.vertical_string_list[self.current_line_num+1] and self.vertical_string_list[self.current_line_num+1].offset - 1 or #self.charlist + 1)
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 separator
                    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
end

-- Allow selection of one or more words
-- 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

    local pos = Geom:new{
        x = ges.pos.x - self.dimen.x,
        y = ges.pos.y - self.dimen.y,
    }

    -- check if the coordinates are actually inside our area
    if pos.x < 0 or pos.x >= self.dimen.w or pos.y < 0 or pos.y >= self.dimen.h then
        pos = nil
    end

    self.hold_start_pos = pos
    self.hold_end_pos = pos
    self:unscheduleClearHighlightAndRedraw()

    if self:updateHighlight() then
        self:redrawHighlight()
    end

    if not self.hold_start_pos then
        return false -- let event be processed by other widgets
    end

    self.hold_start_time = UIManager:getTime()
    return true
end

function TextBoxWidget:onHoldPanText(_, ges)
    -- Let this event pop up if we are not currently selecting text
    if not self.hold_start_pos then
        return false
    end

    self.hold_end_pos = Geom:new{
        x = ges.pos.x - self.dimen.x,
        y = ges.pos.y - self.dimen.y,
    }

    if self:updateHighlight() then
        self.hold_start_time = UIManager:getTime()
        self:redrawHighlight()
    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

    -- check we have seen a HoldStart event
    if not self.hold_start_pos then
        return false
    end

    self.hold_end_pos = Geom:new{
        x = ges.pos.x - self.dimen.x,
        y = ges.pos.y - self.dimen.y,
    }

    if self:updateHighlight() then
        self:redrawHighlight()
    end

    local hold_duration = time.now() - self.hold_start_time

    -- 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 self.hold_end_pos.x > self.width - image.width and self.hold_end_pos.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 reuse 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

    -- Reset start infos, so we do not reuse them and can catch
    -- a missed start event
    self.hold_start_pos = nil
    self.hold_end_pos = nil
    self.hold_start_time = nil

    if self.highlight_start_idx == nil then
        return false
    end

    local selected_text
    if self._xtext then
        selected_text = self._xtext:getText(self.highlight_start_idx, self.highlight_end_idx)
    else
        selected_text = table.concat(self.charlist, "", self.highlight_start_idx, self.highlight_end_idx)
    end

    logger.dbg("onHoldReleaseText (duration:", time.format_time(hold_duration), ") :",
                        self.highlight_start_idx, ">", self.highlight_end_idx, "=", selected_text)
    -- We give index in the charlist (unicode chars), and provide a function
    -- to convert these indices as in the utf8 text, to be used by caller
    -- only if needed, as it may be expensive.
    callback(selected_text, hold_duration, self.highlight_start_idx, self.highlight_end_idx, function(idx) return self:getSourceIndex(idx) end)
    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

function TextBoxWidget:getSourceIndex(char_idx)
    if self._xtext then
        local utf8 = self._xtext:getText(1, char_idx)
        return #utf8
    else
        local utf8 = table.concat(self.charlist, "", 1, char_idx)
        return #utf8
    end
end

function TextBoxWidget:getXtextHighlightIndices(start_x, start_y, end_x, end_y)
    -- 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(start_x, start_y)
    local sel_end_idx = self:getCharPosAtXY(end_x, end_y)
    if not sel_start_idx or not sel_end_idx then
        -- one or both hold points were out of text
        return nil, nil
    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 nil, nil
    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 word_start_idx, word_end_idx = self._xtext:getSelectedWordIndices(sel_start_idx, sel_end_idx, 50)
    return word_start_idx, word_end_idx
end

function TextBoxWidget:getXtextHighlightRects(text_start_idx, text_end_idx, start_line_num, end_line_num)
    local rects = {}
    for line_num = start_line_num, end_line_num, 1 do
        local line = self.vertical_string_list[line_num]
        if line.xglyphs then -- non-empty line
            local line_last_rect = nil
            for _, xglyph in ipairs(line.xglyphs) do
                if xglyph.text_index >= text_start_idx and xglyph.text_index <= text_end_idx and not xglyph.no_drawing then
                    if line_last_rect == nil then
                        local rect = Geom:new{
                            x = xglyph.x0,
                            y = (line_num - self.virtual_line_num) * self.line_height_px,
                            w = xglyph.x1 - xglyph.x0,
                            h = self.line_height_px,
                        }
                        table.insert(rects, rect)
                        line_last_rect = rect
                    else
                        line_last_rect.w = xglyph.x1 - line_last_rect.x
                    end
                end
            end
        end
    end
    return rects
end

function TextBoxWidget:getNonXtextHighlightIndices(start_x, start_y, end_x, end_y)
    local start_idx = self:_findWordEdge(start_x, start_y, FIND_START)
    local end_idx = self:_findWordEdge(end_x, end_y, FIND_END)
    if not start_idx or not end_idx then
        -- one or both hold points were out of text
        return nil, nil
    end
    return start_idx, end_idx
end

function TextBoxWidget:getNonXtextHighlightRects(text_start_idx, text_end_idx, start_line_num, end_line_num)
    local rects = {}
    for line_num = start_line_num, end_line_num, 1 do
        local line = self.vertical_string_list[line_num]
        if line.end_offset then
            local line_last_rect = nil
            local x = 0
            for text_index = line.offset, line.end_offset, 1 do
                local width = self.char_width[self.charlist[text_index]] + (self.idx_pad[text_index] or 0)
                if text_index >= text_start_idx and text_index <= text_end_idx then
                    if line_last_rect == nil then
                        local rect = Geom:new{
                            x = x,
                            y = (line_num - self.virtual_line_num) * self.line_height_px,
                            w = width,
                            h = self.line_height_px,
                        }
                        table.insert(rects, rect)
                        line_last_rect = rect
                    else
                        line_last_rect.w = (x + width) - line_last_rect.x
                    end
                end
                x = x + width
            end
        end
    end
    return rects
end

-- Returns true if the highlight has changed.
function TextBoxWidget:clearHighlight()
    self.hold_start_pos = nil
    self.hold_end_pos = nil
    return self:updateHighlight()
end

-- Returns true if the highlight has changed.
function TextBoxWidget:updateHighlight()
    if not self.hold_start_pos or not self.hold_end_pos then
        local changed = self.highlight_start_idx ~= nil or self.highlight_end_idx ~= nil
        self.highlight_start_idx = nil
        self.highlight_end_idx = nil
        self.highlight_start_line = nil
        self.highlight_end_line = nil
        self.highlight_rects = nil
        return changed
    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_pos.y / self.line_height_px) + self.virtual_line_num - 1
    local end_line_num = math.ceil(self.hold_end_pos.y / self.line_height_px) + self.virtual_line_num - 1
    if start_line_num < end_line_num then
        x0, y0 = self.hold_start_pos.x, self.hold_start_pos.y
        x1, y1 = self.hold_end_pos.x, self.hold_end_pos.y
    elseif start_line_num > end_line_num then
        start_line_num, end_line_num = end_line_num, start_line_num
        x0, y0 = self.hold_end_pos.x, self.hold_end_pos.y
        x1, y1 = self.hold_start_pos.x, self.hold_start_pos.y
    else -- same line_num : sort by x
        if self.hold_start_pos.x <= self.hold_end_pos.x then
            x0, y0 = self.hold_start_pos.x, self.hold_start_pos.y
            x1, y1 = self.hold_end_pos.x, self.hold_end_pos.y
        else
            x0, y0 = self.hold_end_pos.x, self.hold_end_pos.y
            x1, y1 = self.hold_start_pos.x, self.hold_start_pos.y
        end
    end

    local text_start_idx, text_end_idx
    if self.use_xtext then
        text_start_idx, text_end_idx = self:getXtextHighlightIndices(x0, y0, x1, y1)
    else
        text_start_idx, text_end_idx = self:getNonXtextHighlightIndices(x0, y0, x1, y1)
    end

    if start_line_num < 1 then
        start_line_num = 1
    end
    if start_line_num > #self.vertical_string_list then
        start_line_num = #self.vertical_string_list
    end
    if end_line_num < 1 then
        end_line_num = 1
    end
    if end_line_num > #self.vertical_string_list then
        end_line_num = #self.vertical_string_list
    end

    -- Has the highlight range changed?
    if text_start_idx == self.highlight_start_idx and text_end_idx == self.highlight_end_idx
      and start_line_num == self.highlight_start_line and end_line_num == self.highlight_end_line then
        return false
    end

    -- The range has changed, get the rectangles for drawing.
    self.highlight_start_idx = text_start_idx
    self.highlight_end_idx = text_end_idx
    self.highlight_start_line = start_line_num
    self.highlight_end_line = end_line_num
    if text_start_idx == nil or not self.highlight_text_selection then
        self.highlight_rects = nil
    elseif self.use_xtext then
        self.highlight_rects = self:getXtextHighlightRects(text_start_idx, text_end_idx, start_line_num, end_line_num)
    else
        self.highlight_rects = self:getNonXtextHighlightRects(text_start_idx, text_end_idx, start_line_num, end_line_num)
    end
    return true
end

function TextBoxWidget:redrawHighlight()
    if self.highlight_text_selection then
        self:_updateLayout(false)
        UIManager:setDirty(self.dialog or "all", function()
            return "ui", self.dimen
        end)
    end
end

function TextBoxWidget:scheduleClearHighlightAndRedraw()
    if self.highlight_clear_and_redraw_action then
        return
    end

    self.highlight_clear_and_redraw_action = function ()
        self.highlight_clear_and_redraw_action = nil
        if self:clearHighlight() then
            self:redrawHighlight()
        end
    end
    UIManager:scheduleIn(G_defaults:readSetting("DELAY_CLEAR_HIGHLIGHT_S"), self.highlight_clear_and_redraw_action)
end

function TextBoxWidget:unscheduleClearHighlightAndRedraw()
    if self.highlight_clear_and_redraw_action then
        UIManager:unschedule(self.highlight_clear_and_redraw_action)
        self.highlight_clear_and_redraw_action = nil
    end
end

return TextBoxWidget
