From 43ad2cef999a6c793c8535dcd58ba8615cd797a5 Mon Sep 17 00:00:00 2001 From: poire-z Date: Tue, 16 Jan 2018 12:32:49 +0100 Subject: [PATCH] TextBoxWidget: optionally display a list of images (each one at top right of each page) Such images can be loaded dynamically when the display of a page requires it. Allow alternating between the image and its title with Tap on it. Allow for viewing this image zoomed in ImageViewer with Hold on it. DictQuickLookup: add generic support for result.images, that could optionally be provided in Wikipedia lookup results. --- frontend/ui/widget/dictquicklookup.lua | 38 ++- frontend/ui/widget/scrolltextwidget.lua | 5 +- frontend/ui/widget/textboxwidget.lua | 367 +++++++++++++++++++++++- 3 files changed, 392 insertions(+), 18 deletions(-) diff --git a/frontend/ui/widget/dictquicklookup.lua b/frontend/ui/widget/dictquicklookup.lua index 8a6b4200a..6b4ea6c71 100644 --- a/frontend/ui/widget/dictquicklookup.lua +++ b/frontend/ui/widget/dictquicklookup.lua @@ -36,12 +36,14 @@ local DictQuickLookup = InputContainer:new{ dictionary = nil, definition = nil, displayword = nil, + images = nil, is_wiki = false, is_fullpage = false, is_html = false, dict_index = 1, title_face = Font:getFace("x_smalltfont"), content_face = Font:getFace("cfont", DDICT_FONT_SIZE), + image_alt_face = Font:getFace("cfont", DDICT_FONT_SIZE-4), width = nil, height = nil, -- box of highlighted word, quick lookup window tries to not hide the word @@ -185,6 +187,12 @@ end function DictQuickLookup:update() local orig_dimen = self.dict_frame and self.dict_frame.dimen or Geom:new{} + -- Free our previous widget and subwidgets' resources (especially + -- definitions' TextBoxWidget bb, HtmlBoxWidget bb and MuPDF instance, + -- and scheduled image_update_action) + if self[1] then + self[1]:free() + end -- calculate window dimension self.align = "center" self.region = Geom:new{ @@ -288,6 +296,8 @@ function DictQuickLookup:update() dialog = self, -- allow for disabling justification justified = G_reader_settings:nilOrTrue("dict_justify"), + image_alt_face = self.image_alt_face, + images = self.images, } end @@ -335,7 +345,7 @@ function DictQuickLookup:update() Wikipedia:createEpubWithUI(epub_path, self.lookupword, lang, function(success) if success then UIManager:show(ConfirmBox:new{ - text = T(_("Page saved to:\n%1\n\nWould you like to read the downloaded page now?"), epub_path), + text = T(_("Article saved to:\n%1\n\nWould you like to read the downloaded article now?"), epub_path), ok_callback = function() -- close all dict/wiki windows, without scheduleIn(highlight.clear()) self:onHoldClose(true) @@ -356,7 +366,7 @@ function DictQuickLookup:update() }) else UIManager:show(InfoMessage:new{ - text = _("Saving Wikipedia page failed or canceled."), + text = _("Saving Wikipedia article failed or canceled."), }) end end) @@ -524,6 +534,23 @@ function DictQuickLookup:update() end function DictQuickLookup:onCloseWidget() + -- Free our widget and subwidgets' resources (especially + -- definitions' TextBoxWidget bb, HtmlBoxWidget bb and MuPDF instance, + -- and scheduled image_update_action) + if self[1] then + self[1]:free() + end + if self.images_cleanup_needed then + logger.dbg("freeing lookup results images blitbuffers") + for _, r in ipairs(self.results) do + if r.images and #r.images > 0 then + for _, im in ipairs(r.images) do + if im.bb then im.bb:free() end + if im.hi_bb then im.hi_bb:free() end + end + end + end + end UIManager:setDirty(nil, function() return "partial", self.dict_frame.dimen end) @@ -587,6 +614,13 @@ function DictQuickLookup:changeDictionary(index) self.is_html = self.results[index].is_html self.css = self.results[index].css self.lang = self.results[index].lang + self.images = self.results[index].images + if self.images and #self.images > 0 then + -- We'll be giving some images to textboxwidget that will + -- load and display them. We'll need to free these blitbuffers + -- when we're done. + self.images_cleanup_needed = true + end if self.is_fullpage then self.displayword = self.lookupword else diff --git a/frontend/ui/widget/scrolltextwidget.lua b/frontend/ui/widget/scrolltextwidget.lua index 23e9eef30..378fdc440 100644 --- a/frontend/ui/widget/scrolltextwidget.lua +++ b/frontend/ui/widget/scrolltextwidget.lua @@ -28,6 +28,7 @@ local ScrollTextWidget = InputContainer:new{ scroll_bar_width = Screen:scaleBySize(6), text_scroll_span = Screen:scaleBySize(12), dialog = nil, + images = nil, } function ScrollTextWidget:init() @@ -38,9 +39,11 @@ function ScrollTextWidget:init() editable = self.editable, justified = self.justified, face = self.face, + image_alt_face = self.image_alt_face, fgcolor = self.fgcolor, width = self.width - self.scroll_bar_width - self.text_scroll_span, - height = self.height + height = self.height, + images = self.images, } local visible_line_count = self.text_widget:getVisLineCount() local total_line_count = self.text_widget:getAllLineCount() diff --git a/frontend/ui/widget/textboxwidget.lua b/frontend/ui/widget/textboxwidget.lua index 2fd6c4ad4..71a874e81 100644 --- a/frontend/ui/widget/textboxwidget.lua +++ b/frontend/ui/widget/textboxwidget.lua @@ -13,17 +13,24 @@ Example: ]] 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 Widget = require("ui/widget/widget") +local UIManager = require("ui/uimanager") local logger = require("logger") local util = require("util") local Screen = require("device").screen -local TextBoxWidget = Widget:new{ +local TextBoxWidget = InputContainer:new{ text = nil, charlist = nil, charpos = nil, @@ -31,6 +38,7 @@ local TextBoxWidget = Widget:new{ 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, @@ -40,6 +48,30 @@ local TextBoxWidget = Widget:new{ 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() @@ -55,6 +87,10 @@ function TextBoxWidget:init() 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 @@ -63,6 +99,16 @@ function TextBoxWidget:init() 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() @@ -103,9 +149,52 @@ function TextBoxWidget:_splitCharWidthList() local size = #self.char_width_list local ln = 1 local offset, cur_line_width, cur_line_text + + 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 + offset = idx - -- Appending chars until the accumulated width exceeds `self.width`, + -- Appending 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 @@ -116,9 +205,9 @@ function TextBoxWidget:_splitCharWidthList() break end cur_line_width = cur_line_width + self.char_width_list[idx].width - if cur_line_width > self.width then break else idx = idx + 1 end + if cur_line_width > targeted_width then break else idx = idx + 1 end end - if cur_line_width <= self.width then -- a hard newline or end of string + if cur_line_width <= targeted_width then -- a hard newline or end of string cur_line_text = table.concat(self.charlist, "", offset, idx - 1) else -- Backtrack the string until the length fit into one line. @@ -161,7 +250,7 @@ function TextBoxWidget:_splitCharWidthList() -- this line was splitted and can be justified -- we build a list of char_pads, pixels to add to some chars to make the -- whole line justified - local fill_width = self.width - cur_line_width + local fill_width = targeted_width - cur_line_width if fill_width > 0 then local _, nbspaces = string.gsub(cur_line_text, " ", "") if nbspaces > 0 then @@ -200,7 +289,7 @@ function TextBoxWidget:_splitCharWidthList() end end end - end -- endif cur_line_width > self.width + end -- endif cur_line_width > targeted_width if cur_line_width < 0 then break end self.vertical_string_list[ln] = { text = cur_line_text, @@ -229,23 +318,186 @@ 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 - local h = self.line_height_px * row_count + -- 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 - self._bb = Blitbuffer.new(self.width, h) + 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 = self.alignment == "center" and (self.width - line.width)/2 or 0 - --@TODO Don't use kerning for monospaced fonts. (houqp) + 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, line.text, true, self.bold, self.fgcolor, nil, line.char_pads) y = y + self.line_height_px end --- -- if text is shorter than one line, shrink to text's width --- if #v_list == 1 then --- self.width = pen_x --- 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`, @@ -323,9 +575,49 @@ 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() @@ -337,6 +629,7 @@ 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() @@ -384,6 +677,15 @@ function TextBoxWidget:paintTo(bb, x, y) 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 @@ -473,6 +775,41 @@ function TextBoxWidget:onHoldReleaseText(callback, ges) 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 + end + end + end -- Swap start and end if needed local x0, y0, x1, y1 -- first, sort by y/line_num