--[[-- 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 = 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 TimeVal = require("ui/timeval") local UIManager = require("ui/uimanager") local Math = require("optmath") local logger = require("logger") local util = require("util") local Screen = require("device").screen local TextBoxWidget = InputContainer:new{ text = nil, charpos = nil, 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, editable = false, -- Editable flag for whether drawing the cursor or not. justified = false, -- Should text be justified (spaces widened to fill width) alignment = "left", -- or "center", "right" cursor_line = nil, -- LineWidget to draw the vertical cursor. face = nil, bold = nil, line_height = 0.3, -- in em fgcolor = Blitbuffer.COLOR_BLACK, width = Screen:scaleBySize(400), -- in pixels height = nil, -- nil value indicates unscrollable text widget virtual_line_num = 1, -- used by scroll bar _bb = 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 the low-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:_splitCharWidthList() 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, } function TextBoxWidget:init() self.line_height_px = (1 + self.line_height) * self.face.size self.cursor_line = LineWidget:new{ dimen = Geom:new{ w = Size.line.medium, h = self.line_height_px, } } self:_evalCharWidthList() self:_splitCharWidthList() if self.height == nil then self:_renderText(1, #self.vertical_string_list) else -- luajit may segfault if we were provided with a negative height if self.height < 0 then self.height = 0 end self:_renderText(1, self:getVisLineCount()) end if self.editable then local x, y x, y = self:_findCharPos() self.cursor_line:paintTo(self._bb, x, y) end self.dimen = Geom:new(self:getSize()) if Device:isTouchDevice() then self.ges_events = { TapImage = { GestureRange:new{ ges = "tap", range = function() return self.dimen end, }, }, } end end function TextBoxWidget:unfocus() self.editable = false self:init() end function TextBoxWidget:focus() self.editable = true self:init() end -- Split `self.text` into `self.charlist` and evaluate the width of each char in it. function TextBoxWidget:_evalCharWidthList() if self.charlist == nil then self.charlist = util.splitToChars(self.text) self.charpos = #self.charlist + 1 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 -- Split the text into logical lines to fit into the text box. function TextBoxWidget:_splitCharWidthList() self.vertical_string_list = {} local idx = 1 local size = #self.charlist local ln = 1 local offset, end_offset, cur_line_width local lines_per_page if self.height then lines_per_page = self:getVisLineCount() end local image_num = 0 local targeted_width = self.width local image_lines_remaining = 0 while 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 (lines_per_page and ln % lines_per_page == 1) -- first line of a scrolled page or (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 -- We append chars until the accumulated width exceeds `targeted_width`, -- or a newline occurs, or no more chars to consume. cur_line_width = 0 local hard_newline = false while idx <= size do if self.charlist[idx] == "\n" then hard_newline = true break end cur_line_width = cur_line_width + self.char_width[self.charlist[idx]] if cur_line_width > targeted_width then break else idx = idx + 1 end end if cur_line_width <= targeted_width then -- a hard newline or end of string end_offset = idx - 1 else -- Backtrack the string until the length fit into one line. -- We'll give next and prev chars to isSplittable() for a wiser decision local c = self.charlist[idx] local next_c = idx+1 <= size and self.charlist[idx+1] or false local prev_c = idx-1 >= 1 and self.charlist[idx-1] or false local adjusted_idx = idx local adjusted_width = cur_line_width while adjusted_idx > offset and not util.isSplittable(c, next_c, prev_c) do adjusted_width = adjusted_width - self.char_width[self.charlist[adjusted_idx]] adjusted_idx = adjusted_idx - 1 next_c = c c = prev_c prev_c = adjusted_idx-1 >= 1 and self.charlist[adjusted_idx-1] or false end if adjusted_idx == offset or adjusted_idx == idx then -- either a very long english word occupying more than one line, -- or the excessive char is itself splittable: -- we let that excessive char for next line if adjusted_idx == offset then -- let the fact a long word was splitted be known self.has_split_inside_word = true end end_offset = idx - 1 cur_line_width = cur_line_width - self.char_width[self.charlist[idx]] elseif c == " " then -- we backtracked and we're below max width, but the last char -- is a space, we can ignore it end_offset = adjusted_idx - 1 cur_line_width = adjusted_width - self.char_width[self.charlist[adjusted_idx]] idx = adjusted_idx + 1 else -- we backtracked and we're below max width, we can leave the -- splittable char on this line end_offset = adjusted_idx cur_line_width = adjusted_width idx = adjusted_idx + 1 end if self.justified then -- this line was splitted and can be justified -- we record in idx_pad the nb of pixels to add to each char -- to make the whole line justified. This also helps hold -- position accuracy. local fill_width = targeted_width - cur_line_width if fill_width > 0 then local nbspaces = 0 for sidx = offset, end_offset do if self.charlist[sidx] == " " then nbspaces = nbspaces + 1 end end if nbspaces > 0 then -- width added to all spaces local space_add_w = math.floor(fill_width / nbspaces) -- nb of spaces to which we'll add 1 more pixel local space_add1_nb = fill_width - space_add_w * nbspaces for cidx = offset, end_offset do local pad if self.charlist[cidx] == " " then pad = space_add_w if space_add1_nb > 0 then pad = pad + 1 space_add1_nb = space_add1_nb - 1 end if pad > 0 then self.idx_pad[cidx] = pad end end end else -- very long word, or CJK text with no space -- pad first chars with 1 pixel for cidx = offset, end_offset do if fill_width > 0 then self.idx_pad[cidx] = 1 fill_width = fill_width - 1 else break end end end end end end -- endif cur_line_width > targeted_width if cur_line_width < 0 then break end self.vertical_string_list[ln] = { offset = offset, end_offset = end_offset, width = cur_line_width, } if hard_newline then idx = idx + 1 -- end_offset = nil means no text self.vertical_string_list[ln+1] = {offset = idx, end_offset = nil, width = 0} else -- If next char is a space, discard it so it does not become -- an ugly leading space on the next line if idx <= size and self.charlist[idx] == " " then idx = idx + 1 end end ln = ln + 1 -- Make sure `idx` point to the next char to be processed in the next loop. 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 function TextBoxWidget:geCharWidth(idx) return self.char_width[self.charlist[idx]] end function TextBoxWidget:_renderText(start_row_idx, end_row_idx) local font_height = self.face.size if start_row_idx < 1 then start_row_idx = 1 end if end_row_idx > #self.vertical_string_list then end_row_idx = #self.vertical_string_list end local row_count = end_row_idx == 0 and 1 or end_row_idx - start_row_idx + 1 -- We need a bb with the full height (even if we display only a few lines, we -- may have to draw an image bigger than these lines) local h = self.height or self.line_height_px * row_count if self._bb then self._bb:free() end local bbtype = nil if self.line_num_to_image and self.line_num_to_image[start_row_idx] then -- Whether Screen:isColorEnabled() or not, it's best to always use BBRGB32 -- and alphablitFrom() for the best display of various images: -- With greyscale screen TYPE_BB8 (the default, and what we would -- have chosen when not Screen:isColorEnabled()): -- alphablitFrom: some images are all white (ex: flags on Milan, Ilkhanides on wiki.fr) -- blitFrom: some images have a black background (ex: RDA, Allemagne on wiki.fr) -- With TYPE_BBRGB32: -- blitFrom: some images have a black background (ex: RDA, Allemagne on wiki.fr) -- alphablitFrom: all these images looks good, with a white background bbtype = Blitbuffer.TYPE_BBRGB32 end self._bb = Blitbuffer.new(self.width, h, bbtype) self._bb:fill(Blitbuffer.COLOR_WHITE) local y = font_height 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 --@todo don't use kerning for monospaced fonts. (houqp) -- refert to cb25029dddc42693cc7aaefbe47e9bd3b7e1a750 in master tree RenderText:renderUtf8Text(self._bb, pen_x, y, self.face, self:_getLineText(line), true, self.bold, self.fgcolor, nil, self:_getLinePads(line)) y = y + self.line_height_px end -- Render image if any self:_renderImage(start_row_idx) end function TextBoxWidget:_renderImage(start_row_idx) local scheduled_update = self.scheduled_update self.scheduled_update = nil -- reset it, so we don't have to whenever we return below if not self.line_num_to_image or not self.line_num_to_image[start_row_idx] then 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 self._bb:alphablitFrom(image.bb, self.width - image.width, 0) 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_GREY, bold = true, } status_height = status_widget:getSize().h status_widget = FrameContainer:new{ background = Blitbuffer.COLOR_WHITE, bordersize = 0, margin = 0, padding = 0, RightContainer:new{ dimen = { w = image.width, h = status_height, }, status_widget, }, } status_widget:paintTo(self._bb, self.width - image.width, image.height - status_height) status_widget:free() end if display_alt then local alt_widget = TextBoxWidget:new{ text = alt_text, face = self.image_alt_face, fgcolor = self.image_alt_fgcolor, width = image.width, -- don't draw over status_text if any height = math.max(0, image.height - status_height), } alt_widget:paintTo(self._bb, self.width - image.width, 0) alt_widget:free() end if do_schedule_update then if self.image_update_action then -- Cancel any previous one, if we changed page quickly UIManager:unschedule(self.image_update_action) end -- Remember on which page we were launched, so we can -- abort if page has changed local scheduled_for_linenum = start_row_idx self.image_update_action = function() self.image_update_action = nil if scheduled_for_linenum ~= self.virtual_line_num then return -- no more on this page end local dismissed = image.load_bb_func() -- will update self.bb (or not if failure) if dismissed then -- If dismissed, the dismiss event may be resent, we -- may soon just go display another page. So delay this update a -- bit to see if that happened UIManager:scheduleIn(0.1, function() if scheduled_for_linenum == self.virtual_line_num then -- we are still on the same page self:update(true) UIManager:setDirty("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, } 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("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, } 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 -- Return the position of the cursor corresponding to `self.charpos`, -- Be aware of virtual line number of the scorllTextWidget. function TextBoxWidget:_findCharPos() if self.text == nil or string.len(self.text) == 0 then return 0, 0 end -- Find the line number. local ln = self.height == nil and 1 or self.virtual_line_num while ln + 1 <= #self.vertical_string_list do if self.vertical_string_list[ln + 1].offset > self.charpos then break else ln = ln + 1 end end -- Find the offset at the current line. local x = 0 local offset = self.vertical_string_list[ln].offset while offset < self.charpos do x = x + self.char_width[self.charlist[offset]] + (self.idx_pad[offset] or 0) offset = offset + 1 end return x + 1, (ln - 1) * self.line_height_px -- offset `x` by 1 to avoid overlap end function TextBoxWidget:moveCursorToCharpos(charpos) self.charpos = charpos local x, y = self:_findCharPos() self.cursor_line:paintTo(self._bb, x, y) end -- Click event: Move the cursor to a new location with (x, y), in pixels. -- Be aware of virtual line number of the scorllTextWidget. function TextBoxWidget:moveCursor(x, y) if x < 0 or y < 0 then return end if #self.vertical_string_list == 0 then -- if there's no text at all, nothing to do return 1 end local w = 0 local ln = self.height == nil and 1 or self.virtual_line_num ln = ln + math.ceil(y / self.line_height_px) - 1 if ln > #self.vertical_string_list then ln = #self.vertical_string_list x = self.width end local offset = self.vertical_string_list[ln].offset local idx = ln == #self.vertical_string_list and #self.charlist or self.vertical_string_list[ln + 1].offset - 1 while offset <= idx do w = w + self.char_width[self.charlist[offset]] + (self.idx_pad[offset] or 0) if w > x then break else offset = offset + 1 end end if w > x then local w_prev = w - self.char_width[self.charlist[offset]] - (self.idx_pad[offset] or 0) if x - w_prev < w - x then -- the previous one is more closer w = w_prev else offset = offset + 1 end end self:free() self:_renderText(1, #self.vertical_string_list) self.cursor_line:paintTo(self._bb, w + 1, (ln - self.virtual_line_num) * self.line_height_px) return offset end function TextBoxWidget:getVisLineCount() return math.floor(self.height / self.line_height_px) end function TextBoxWidget:getAllLineCount() return #self.vertical_string_list end function TextBoxWidget:update(scheduled_update) self:free() -- We set this flag so :_renderText() can know we were called from a -- scheduled update and so not schedule another one self.scheduled_update = scheduled_update self:_renderText(self.virtual_line_num, self.virtual_line_num + self:getVisLineCount() - 1) self.scheduled_update = nil end function TextBoxWidget:onTapImage(arg, ges) if self.line_num_to_image and self.line_num_to_image[self.virtual_line_num] then local image = self.line_num_to_image[self.virtual_line_num] local tap_x = ges.pos.x - self.dimen.x local tap_y = ges.pos.y - self.dimen.y -- Check that this tap is on this image if tap_x > self.width - image.width and tap_x < self.width and tap_y > 0 and tap_y < image.height then logger.dbg("tap on image") if image.bb then -- Toggle between image and alt_text self.image_show_alt_text = not self.image_show_alt_text self:update() UIManager:setDirty("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, } end) return true end end end end -- TODO: modify `charpos` so that it can render the cursor function TextBoxWidget:scrollDown() self.image_show_alt_text = nil -- reset image bb/alt state local visible_line_count = self:getVisLineCount() if self.virtual_line_num + visible_line_count <= #self.vertical_string_list then self:free() self.virtual_line_num = self.virtual_line_num + visible_line_count self:_renderText(self.virtual_line_num, self.virtual_line_num + visible_line_count - 1) end return (self.virtual_line_num - 1) / #self.vertical_string_list, (self.virtual_line_num - 1 + visible_line_count) / #self.vertical_string_list end -- TODO: modify `charpos` so that it can render the cursor function TextBoxWidget:scrollUp() self.image_show_alt_text = nil local visible_line_count = self:getVisLineCount() if self.virtual_line_num > 1 then self:free() if self.virtual_line_num <= visible_line_count then self.virtual_line_num = 1 else self.virtual_line_num = self.virtual_line_num - visible_line_count end self:_renderText(self.virtual_line_num, self.virtual_line_num + visible_line_count - 1) end return (self.virtual_line_num - 1) / #self.vertical_string_list, (self.virtual_line_num - 1 + visible_line_count) / #self.vertical_string_list end function TextBoxWidget:scrollToRatio(ratio) self.image_show_alt_text = nil ratio = math.max(0, math.min(1, ratio)) -- ensure ratio is between 0 and 1 (100%) local visible_line_count = self:getVisLineCount() local page_count = 1 + math.floor((#self.vertical_string_list - 1) / visible_line_count) local page_num = 1 + Math.round((page_count - 1) * ratio) local line_num = 1 + (page_num - 1) * visible_line_count if line_num ~= self.virtual_line_num then self:free() self.virtual_line_num = line_num self:_renderText(self.virtual_line_num, self.virtual_line_num + visible_line_count - 1) end return (self.virtual_line_num - 1) / #self.vertical_string_list, (self.virtual_line_num - 1 + visible_line_count) / #self.vertical_string_list end function TextBoxWidget:getSize() if self.width and self.height then return Geom:new{ w = self.width, h = self.height} else return Geom:new{ w = self.width, h = self._bb:getHeight()} end end function TextBoxWidget:moveCursorUp() if self.vertical_string_list and #self.vertical_string_list < 2 then return end local x, y x, y = self:_findCharPos() local charpos = self:moveCursor(x, y - self.line_height_px +1) if charpos then self:moveCursorToCharpos(charpos) end return charpos end function TextBoxWidget:moveCursorDown() if self.vertical_string_list and #self.vertical_string_list < 2 then return end local x, y x, y = self:_findCharPos() local charpos = self:moveCursor(x, y + self.line_height_px +1) if charpos then self:moveCursorToCharpos(charpos) end return charpos 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:free() logger.dbg("TextBoxWidget:free called") -- :free() is called when our parent widget is closing, and -- here whenever :_renderText() is being called, to display -- a new page: cancel any scheduled image update, as it -- is no more related to current page if self.image_update_action then logger.dbg("TextBoxWidget:free: cancelling self.image_update_action") UIManager:unschedule(self.image_update_action) end if self._bb then self._bb:free() self._bb = nil end end -- Allow selection of a single word at hold position function TextBoxWidget:onHoldWord(callback, ges) if not callback then return end local x, y = ges.pos.x - self.dimen.x, ges.pos.y - self.dimen.y local line_num = math.ceil(y / self.line_height_px) + self.virtual_line_num-1 local line = self.vertical_string_list[line_num] logger.dbg("holding on line", line) if line then local char_start = line.offset local char_end -- char_end is non-inclusive if line_num >= #self.vertical_string_list then char_end = #self.charlist + 1 else char_end = self.vertical_string_list[line_num+1].offset end local char_probe_x = 0 local idx = char_start -- find which character the touch is holding while idx < char_end do -- FIXME: this might break if kerning is enabled char_probe_x = char_probe_x + self.char_width[self.charlist[idx]] + (self.idx_pad[idx] or 0) if char_probe_x > x then -- ignore spaces if self.charlist[idx] == " " then break end -- now find which word the character is in local words = util.splitToWords(self:_getLineText(line)) local probe_idx = char_start for _, w in ipairs(words) do -- +1 for word separtor probe_idx = probe_idx + #util.splitToChars(w) if idx <= probe_idx - 1 then callback(w) return end end break end idx = idx + 1 end end return end -- Allow selection of one or more words (with no visual feedback) -- Gestures should be declared in widget using us (e.g dictquicklookup.lua) -- Constants for which side of a word to find local FIND_START = 1 local FIND_END = 2 function TextBoxWidget:onHoldStartText(_, ges) -- store hold start position and timestamp, will be used on release self.hold_start_x = ges.pos.x - self.dimen.x self.hold_start_y = ges.pos.y - self.dimen.y -- check coordinates are actually inside our area if self.hold_start_x < 0 or self.hold_start_x > self.dimen.w or self.hold_start_y < 0 or self.hold_start_y > self.dimen.h then self.hold_start_tv = nil -- don't process coming HoldRelease event return false -- let event be processed by other widgets end self.hold_start_tv = TimeVal.now() return true end function TextBoxWidget:onHoldPanText(_, ges) -- We don't highlight the currently selected text, but just let this -- event pop up if we are not currently selecting text if not self.hold_start_tv then return false end -- Don't let that event be processed by other widget return true end function TextBoxWidget:onHoldReleaseText(callback, ges) if not callback then return end local hold_end_x = ges.pos.x - self.dimen.x local hold_end_y = ges.pos.y - self.dimen.y -- check we have seen a HoldStart event if not self.hold_start_tv then return false end -- check start and end coordinates are actually inside our area if self.hold_start_x < 0 or hold_end_x < 0 or self.hold_start_x > self.dimen.w or hold_end_x > self.dimen.w or self.hold_start_y < 0 or hold_end_y < 0 or self.hold_start_y > self.dimen.h or hold_end_y > self.dimen.h then return false end local hold_duration = TimeVal.now() - self.hold_start_tv hold_duration = hold_duration.sec + hold_duration.usec/1000000 -- If page contains an image, check if Hold is on this image and deal -- with it directly if self.line_num_to_image and self.line_num_to_image[self.virtual_line_num] then local image = self.line_num_to_image[self.virtual_line_num] if hold_end_x > self.width - image.width and hold_end_y < image.height then -- Only if low-res image is loaded, so we have something to display -- if high-res loading is not implemented or if its loading fails if image.bb then logger.dbg("hold on image") local load_and_show_image = function() if not image.hi_bb and image.load_bb_func then image.load_bb_func(true) -- load high res image if implemented end -- display hi_bb, or low-res bb if hi_bb has not been -- made (if not implemented, or failed, or dismissed) local ImageViewer = require("ui/widget/imageviewer") local imgviewer = ImageViewer:new{ image = image.hi_bb or image.bb, -- fallback to low-res if high-res failed image_disposable = false, -- we may re-use our bb if called again with_title_bar = true, title_text = image.title, caption = image.caption, fullscreen = true, } UIManager:show(imgviewer) end -- Wrap it with Trapper, as load_bb_func may be using some of its -- dismissable methods local Trapper = require("ui/trapper") UIManager:scheduleIn(0.1, function() Trapper:wrap(load_and_show_image) end) -- And we return without calling the "Hold on text" callback return true end end end -- Swap start and end if needed local x0, y0, x1, y1 -- first, sort by y/line_num local start_line_num = math.ceil(self.hold_start_y / self.line_height_px) local end_line_num = math.ceil(hold_end_y / self.line_height_px) if start_line_num < end_line_num then x0, y0 = self.hold_start_x, self.hold_start_y x1, y1 = hold_end_x, hold_end_y elseif start_line_num > end_line_num then x0, y0 = hold_end_x, hold_end_y x1, y1 = self.hold_start_x, self.hold_start_y else -- same line_num : sort by x if self.hold_start_x <= hold_end_x then x0, y0 = self.hold_start_x, self.hold_start_y x1, y1 = hold_end_x, hold_end_y else x0, y0 = hold_end_x, hold_end_y x1, y1 = self.hold_start_x, self.hold_start_y end end -- Reset start infos, so we do not reuse them and can catch -- a missed start event self.hold_start_x = nil self.hold_start_y = nil self.hold_start_tv = nil -- similar code to find start or end is in _findWordEdge() helper local sel_start_idx = self:_findWordEdge(x0, y0, FIND_START) local sel_end_idx = self:_findWordEdge(x1, y1, FIND_END) if not sel_start_idx or not sel_end_idx then -- one or both hold points were out of text return true end local selected_text = table.concat(self.charlist, "", sel_start_idx, sel_end_idx) logger.dbg("onHoldReleaseText (duration:", hold_duration, ") :", sel_start_idx, ">", sel_end_idx, "=", selected_text) callback(selected_text, hold_duration) return true end function TextBoxWidget:_findWordEdge(x, y, side) if side ~= FIND_START and side ~= FIND_END then return end local line_num = math.ceil(y / self.line_height_px) + self.virtual_line_num-1 local line = self.vertical_string_list[line_num] if not line then return -- below last line : no selection end local char_start = line.offset local char_end -- char_end is non-inclusive if line_num >= #self.vertical_string_list then char_end = #self.charlist + 1 else char_end = self.vertical_string_list[line_num+1].offset end local char_probe_x = 0 local idx = char_start local edge_idx = nil -- find which character the touch is holding while idx < char_end do char_probe_x = char_probe_x + self.char_width[self.charlist[idx]] + (self.idx_pad[idx] or 0) if char_probe_x > x then -- character found, find which word the character is in, and -- get its start/end idx local words = util.splitToWords(self:_getLineText(line)) -- words may contain separators (space, punctuation) : we don't -- discriminate here, it's the caller job to clean what was -- selected local probe_idx = char_start local next_probe_idx for _, w in ipairs(words) do next_probe_idx = probe_idx + #util.splitToChars(w) if idx < next_probe_idx then if side == FIND_START then edge_idx = probe_idx elseif side == FIND_END then edge_idx = next_probe_idx - 1 end break end probe_idx = next_probe_idx end if edge_idx then break end end idx = idx + 1 end return edge_idx end return TextBoxWidget