From 7166efd77793198f60666574607124417035eaab Mon Sep 17 00:00:00 2001 From: poire-z Date: Thu, 26 Jan 2017 08:56:24 +0100 Subject: [PATCH] ImageViewer: added zoom & pan via gestures ImageWidget: removed 'autostretch' setting (replaced by scale_factor=0) and renamed 'autoscale' setting to 'scale_for_dpi'. --- frontend/ui/screensaver.lua | 4 +- frontend/ui/widget/bookstatuswidget.lua | 1 - frontend/ui/widget/iconbutton.lua | 4 +- frontend/ui/widget/imageviewer.lua | 210 +++++++++++++---- frontend/ui/widget/imagewidget.lua | 224 +++++++++++++++---- plugins/goodreads.koplugin/goodreadsbook.lua | 2 - 6 files changed, 349 insertions(+), 96 deletions(-) diff --git a/frontend/ui/screensaver.lua b/frontend/ui/screensaver.lua index 1d449afb8..d35d88f25 100644 --- a/frontend/ui/screensaver.lua +++ b/frontend/ui/screensaver.lua @@ -33,7 +33,7 @@ local function createWidgetFromFile(file) file_do_cache = false, height = Screen:getHeight(), width = Screen:getWidth(), - autostretch = true, + scale_factor = 0, -- scale to fit height/width }) end end @@ -82,7 +82,7 @@ function Screensaver:getCoverImage(file) image = image, height = Screen:getHeight(), width = Screen:getWidth(), - autostretch = doc_settings:readSetting("proportional_screensaver"), + scale_factor = doc_settings:readSetting("proportional_screensaver") and 0 or nil, } return createWidgetFromImage(img_widget) end diff --git a/frontend/ui/widget/bookstatuswidget.lua b/frontend/ui/widget/bookstatuswidget.lua index 21dc5cfbb..5e750499f 100644 --- a/frontend/ui/widget/bookstatuswidget.lua +++ b/frontend/ui/widget/bookstatuswidget.lua @@ -298,7 +298,6 @@ function BookStatusWidget:genBookInfoGroup() image = self.thumbnail, width = img_width, height = img_height, - autoscale = false, }) end diff --git a/frontend/ui/widget/iconbutton.lua b/frontend/ui/widget/iconbutton.lua index 41ef0ed03..cce03e925 100644 --- a/frontend/ui/widget/iconbutton.lua +++ b/frontend/ui/widget/iconbutton.lua @@ -11,14 +11,14 @@ local IconButton = InputContainer:new{ dimen = nil, -- show_parent is used for UIManager:setDirty, so we can trigger repaint show_parent = nil, - autoscale = true, + scale_for_dpi = true, callback = function() end, } function IconButton:init() self.image = ImageWidget:new{ file = self.icon_file, - autoscale = self.autoscale, + scale_for_dpi = self.scale_for_dpi, } self.show_parent = self.show_parent or self diff --git a/frontend/ui/widget/imageviewer.lua b/frontend/ui/widget/imageviewer.lua index 7f2a75ecf..843589206 100644 --- a/frontend/ui/widget/imageviewer.lua +++ b/frontend/ui/widget/imageviewer.lua @@ -19,6 +19,7 @@ local logger = require("logger") local _ = require("gettext") local Blitbuffer = require("ffi/blitbuffer") + --[[ Display image with some simple manipulation options ]] @@ -38,7 +39,7 @@ local ImageViewer = InputContainer:new{ width = nil, height = nil, - stretched = true, -- start with image scaled for best fit + scale_factor = 0, -- start with image scaled for best fit rotated = false, -- we use this global setting for rotation angle to have the same angle as reader rotation_angle = DLANDSCAPE_CLOCKWISE_ROTATION and 90 or 270, @@ -49,6 +50,12 @@ local ImageViewer = InputContainer:new{ image_padding = Screen:scaleBySize(2), button_padding = Screen:scaleBySize(14), + _scale_to_fit = nil, -- state of toggle between our 2 pre-defined scales (scale to fit / original size) + _panning = false, + -- Default centering on center of image if oversized + _center_x_ratio = 0.5, + _center_y_ratio = 0.5, + -- Reference to current ImageWidget instance, for cleaning _image_wg = nil, } @@ -56,31 +63,32 @@ local ImageViewer = InputContainer:new{ function ImageViewer:init() if Device:hasKeys() then self.key_events = { - Close = { {"Back"}, doc = "close viewer" } + Close = { {"Back"}, doc = "close viewer" }, + ZoomIn = { {Device.input.group.PgBack}, doc = "Zoom In" }, + ZoomOut = { {Device.input.group.PgFwd}, doc = "Zoom out" }, } end if Device:isTouchDevice() then + local range = Geom:new{ + x = 0, y = 0, + w = Screen:getWidth(), + h = Screen:getHeight(), + } self.ges_events = { - Tap = { - GestureRange:new{ - ges = "tap", - range = Geom:new{ - x = 0, y = 0, - w = Screen:getWidth(), - h = Screen:getHeight(), - } - }, - }, - Swipe = { - GestureRange:new{ - ges = "swipe", - range = Geom:new{ - x = 0, y = 0, - w = Screen:getWidth(), - h = Screen:getHeight(), - } - }, - }, + Tap = { GestureRange:new{ ges = "tap", range = range } }, + -- Zoom in/out (Pinch & Spread are not triggered if user is too + -- slow and Hold event is decided first) + Spread = { GestureRange:new{ ges = "spread", range = range } }, + Pinch = { GestureRange:new{ ges = "pinch", range = range } }, + -- All the following gestures will allow easy panning + -- Hold happens if we hold at start + -- Pan happens if we don't hold at start, but hold at end + -- Swipe happens if we don't hold at any moment + Hold = { GestureRange:new{ ges = "hold", range = range } }, + HoldRelease = { GestureRange:new{ ges = "hold_release", range = range } }, + Pan = { GestureRange:new{ ges = "pan", range = range } }, + PanRelease = { GestureRange:new{ ges = "pan_release", range = range } }, + Swipe = { GestureRange:new{ ges = "swipe", range = range } }, } end self:update() @@ -98,6 +106,9 @@ end function ImageViewer:update() self:_clean_image_wg() -- clean previous if any + if self._scale_to_fit == nil then -- initialize our toggle + self._scale_to_fit = self.scale_factor == 0 and true or false + end local orig_dimen = self.main_frame and self.main_frame.dimen or Geom:new{} self.align = "center" self.region = Geom:new{ @@ -116,9 +127,13 @@ function ImageViewer:update() local buttons = { { { - text = self.stretched and _("Original size") or _("Scale"), + text = self._scale_to_fit and _("Original size") or _("Scale"), callback = function() - self.stretched = not self.stretched and true or false + self.scale_factor = self._scale_to_fit and 1 or 0 + self._scale_to_fit = not self._scale_to_fit + -- Reset center ratio (may have been modified if some panning was done) + self._center_x_ratio = 0.5 + self._center_y_ratio = 0.5 self:update() end, }, @@ -190,29 +205,18 @@ function ImageViewer:update() local max_image_h = img_container_h - self.image_padding*2 local max_image_w = self.width - self.image_padding*2 - -- Do a first rendering without our h/w to get native image size and see if it needs to be reduced self._image_wg = ImageWidget:new{ file = self.file, image = self.image, image_disposable = false, -- we may re-use self.image alpha = true, - pre_rotate = self.rotated and self.rotation_angle or 0, + width = max_image_w, + height = max_image_h, + rotation_angle = self.rotated and self.rotation_angle or 0, + scale_factor = self.scale_factor, + center_x_ratio = self._center_x_ratio, + center_y_ratio = self._center_y_ratio, } - local imwg_size = self._image_wg:getSize() - if self.stretched or imwg_size.w > max_image_w or imwg_size.h > max_image_h then - -- 2nd rendering if it needs to be stretched to fit our size - self:_clean_image_wg() -- clean previous ImageWidget._bb - self._image_wg = ImageWidget:new{ - file = self.file, - image = self.image, - image_disposable = false, -- we may re-use self.image - alpha = true, - width = max_image_w, - height = max_image_h, - autostretch = true, - pre_rotate = self.rotated and self.rotation_angle or 0, - } - end local image_container = CenterContainer:new{ dimen = Geom:new{ @@ -269,7 +273,7 @@ function ImageViewer:onShow() return true end -function ImageViewer:onTap(arg, ges) +function ImageViewer:onTap(_, ges) if ges.pos:notIntersectWith(self.main_frame.dimen) then self:onClose() return true @@ -277,9 +281,125 @@ function ImageViewer:onTap(arg, ges) return true end -function ImageViewer:onSwipe(arg, ges) - -- trigger full refresh - UIManager:setDirty(nil, "full") +function ImageViewer:panBy(x, y) + if self._image_wg then + -- ImageWidget:panBy() returns new center ratio, so we update ours, + -- so we'll be centered the same way when we zoom in or out + self._center_x_ratio, self._center_y_ratio = self._image_wg:panBy(x, y) + end +end + +-- Panning events +function ImageViewer:onSwipe(_, ges) + -- Panning with swipe is less accurate, as we don't get both coordinates, + -- only start point + direction (with only 45° granularity) + local direction = ges.direction + local distance = ges.distance + local sq_distance = math.sqrt(distance*distance/2) + if direction == "north" then + self:panBy(0, distance) + elseif direction == "south" then + self:panBy(0, -distance) + elseif direction == "east" then + self:panBy(-distance, 0) + elseif direction == "west" then + self:panBy(distance, 0) + elseif direction == "northeast" then + self:panBy(-sq_distance, sq_distance) + elseif direction == "northwest" then + self:panBy(sq_distance, sq_distance) + elseif direction == "southeast" then + self:panBy(-sq_distance, -sq_distance) + elseif direction == "southwest" then + self:panBy(sq_distance, -sq_distance) + end + return true +end + +function ImageViewer:onHold(_, ges) + -- Start of pan + self._panning = true + self._pan_relative_x = ges.pos.x + self._pan_relative_y = ges.pos.y + return true +end + +function ImageViewer:onHoldRelease(_, ges) + -- End of pan + if self._panning then + self._panning = false + self._pan_relative_x = ges.pos.x - self._pan_relative_x + self._pan_relative_y = ges.pos.y - self._pan_relative_y + if self._pan_relative_x == 0 and self._pan_relative_y == 0 then + -- Hold with no move: use this to trigger full refresh + UIManager:setDirty(nil, "full") + else + self:panBy(-self._pan_relative_x, -self._pan_relative_y) + end + end + return true +end + +function ImageViewer:onPan(_, ges) + self._panning = true + self._pan_relative_x = ges.relative.x + self._pan_relative_y = ges.relative.y + return true +end + +function ImageViewer:onPanRelease(_, ges) + if self._panning then + self._panning = false + self:panBy(-self._pan_relative_x, -self._pan_relative_y) + end + return true +end + +-- Zoom events +function ImageViewer:onZoomIn(inc) + if self.scale_factor == 0 then + -- Get the scale_factor made out for best fit + self.scale_factor = self._image_wg:getScaleFactor() + end + if not inc then inc = 0.2 end -- default for key zoom event + if self.scale_factor + inc < 100 then -- avoid excessive zoom + self.scale_factor = self.scale_factor + inc + self:update() + end + return true +end + +function ImageViewer:onZoomOut(dec) + if self.scale_factor == 0 then + -- Get the scale_factor made out for best fit + self.scale_factor = self._image_wg:getScaleFactor() + end + if not dec then dec = 0.2 end -- default for key zoom event + if self.scale_factor - dec > 0.01 then -- avoid excessive unzoom + self.scale_factor = self.scale_factor - dec + self:update() + end + return true +end + +function ImageViewer:onSpread(_, ges) + -- We get the position where spread was done + -- First, get center ratio we would have had if we did a pan to there, + -- so we can have the zoom centered on there + if self._image_wg then + self._center_x_ratio, self._center_y_ratio = self._image_wg:getPanByCenterRatio(ges.pos.x - Screen:getWidth()/2, ges.pos.y - Screen:getHeight()/2) + end + -- Set some zoom increase value from pinch distance + local inc = ges.distance / Screen:getWidth() + self:onZoomIn(inc) + return true +end + +function ImageViewer:onPinch(_, ges) + -- With Pinch, unlike Spread, it feels more natural if we keep the same center point. + -- Set some zoom decrease value from pinch distance + local dec = ges.distance / Screen:getWidth() + self:onZoomOut(dec) return true end diff --git a/frontend/ui/widget/imagewidget.lua b/frontend/ui/widget/imagewidget.lua index 9b149a333..2ad2efe8a 100644 --- a/frontend/ui/widget/imagewidget.lua +++ b/frontend/ui/widget/imagewidget.lua @@ -22,6 +22,7 @@ Show image from memory example: local Widget = require("ui/widget/widget") local Screen = require("device").screen +local UIManager = require("ui/uimanager") local CacheItem = require("cacheitem") local Mupdf = require("ffi/mupdf") local Geom = require("ui/geometry") @@ -57,27 +58,56 @@ local ImageWidget = Widget:new{ -- normally true unless our caller wants to reuse it's provided image image_disposable = true, - invert = nil, - dim = nil, - hide = nil, - -- if width or height is given, image will rescale to the given size + -- Width and height of container, to limit rendering to this area + -- (if provided, and scale_factor is nil, image will be resized to + -- these width and height without regards to original aspect ratio) width = nil, height = nil, - -- if autoscale is true image will be rescaled according to screen dpi - autoscale = false, - -- when alpha is set to true, alpha values from the image will be honored - alpha = false, - -- when autostretch is set to true, image will be stretched to best fit the - -- widget size. i.e. either fit the width or fit the height according to the - -- original image size. - autostretch = false, - -- when pre_rotate is not 0, native image is rotated by this angle - -- before applying the other autostretch/autoscale settings - pre_rotate = 0, - -- former 'overflow' setting removed, as logic was wrong + + hide = nil, -- to not be painted + + -- Settings that apply at paintTo() time + invert = nil, + dim = nil, + alpha = false, -- honors alpha values from the image + + -- When rotation_angle is not 0, native image is rotated by this angle + -- before scaling. + rotation_angle = 0, + + -- If scale_for_dpi is true image will be rescaled according to screen dpi + -- (x2 if DPI > 332) - (formerly known as 'autoscale') + scale_for_dpi = false, + + -- When scale_factor is not nil, native image is scaled by this factor + -- (if scale_factor == 1, native image size is kept) + -- Special case : scale_factor == 0 : image will be scaled to best fit provided + -- width and height, keeping aspect ratio (scale_factor will be updated + -- from 0 to the factor used at _render() time) + -- (former 'autostrech' setting removed, use "scale_factor=0" instead) + scale_factor = nil, + + -- For initial positionning, if (possibly scaled) image overflows width/height + center_x_ratio = 0.5, -- default is centered on image's center + center_y_ratio = 0.5, + + -- For pan & zoom management: + -- offsets to use in blitFrom() + _offset_x = 0, + _offset_y = 0, + -- limits to center_x_ratio variation around 0.5 (0.5 +/- these values) + -- to keep image centered (0 means center_x_ratio will be forced to 0.5) + _max_off_center_x_ratio = 0, + _max_off_center_y_ratio = 0, + + -- So we can reset self.scale_factor to its initial value in free(), in + -- case this same object is free'd but re-used and and re-render'ed + _initial_scale_factor = nil, _bb = nil, - _bb_disposable = true -- whether we should free() our _bb + _bb_disposable = true, -- whether we should free() our _bb + _bb_w = nil, + _bb_h = nil, } function ImageWidget:_loadimage() @@ -121,6 +151,7 @@ function ImageWidget:_render() if self._bb then -- already rendered return end + logger.dbg("ImageWidget: _render'ing") if self.image then self:_loadimage() elseif self.file then @@ -128,68 +159,169 @@ function ImageWidget:_render() else error("cannot render image") end - if self.pre_rotate ~= 0 then + + -- Store initial scale factor + self._initial_scale_factor = self.scale_factor + + -- First, rotation + if self.rotation_angle ~= 0 then if not self._bb_disposable then -- we can't modify _bb, make a copy self._bb = self._bb:copy() self._bb_disposable = true -- new object will have to be freed end - self._bb:rotate(self.pre_rotate) -- rotate in-place + self._bb:rotate(self.rotation_angle) -- rotate in-place end - local native_w, native_h = self._bb:getWidth(), self._bb:getHeight() - local w, h = self.width, self.height - if self.autoscale then + + local bb_w, bb_h = self._bb:getWidth(), self._bb:getHeight() + + -- scale_for_dpi setting: update scale_factor (even if not set) with it + if self.scale_for_dpi then local dpi_scale = Screen:getDPI() / 167 -- rounding off to power of 2 to avoid alias with pow(2, floor(log(x)/log(2)) - local scale = math.pow(2, math.max(0, math.floor(math.log(dpi_scale)/0.69))) - w, h = scale * native_w, scale * native_h - elseif self.width and self.height then - if self.autostretch then - local ratio = native_w / self.width / native_h * self.height - if ratio < 1 then - h = self.height - w = self.width * ratio - else - h = self.height / ratio - w = self.width - end + dpi_scale = math.pow(2, math.max(0, math.floor(math.log(dpi_scale)/0.69))) + if self.scale_factor == nil then + self.scale_factor = 1 end + self.scale_factor = self.scale_factor * dpi_scale end - if (w and w ~= native_w) or (h and h ~= native_h) then - -- We're making a new blitbuffer, we need to explicitely free + + -- scale to best fit container : compute scale_factor for that + if self.scale_factor == 0 then + if self.width and self.height then + self.scale_factor = math.min(self.width / bb_w, self.height / bb_h) + logger.dbg("ImageWidget: scale to fit, setting scale_factor to", self.scale_factor) + else + -- no width and height provided (inconsistencies from caller), + self.scale_factor = 1 -- native image size + end + end + + -- replace blitbuffer with a resizd one if needed + local new_bb = nil + if self.scale_factor == nil then + -- no scaling, but strech to width and height, only if provided + if self.width and self.height then + logger.dbg("ImageWidget: stretching") + new_bb = self._bb:scale(self.width, self.height) + end + elseif self.scale_factor ~= 1 then + -- scale by scale_factor (not needed if scale_factor == 1) + logger.dbg("ImageWidget: scaling by", self.scale_factor) + new_bb = self._bb:scale(bb_w * self.scale_factor, bb_h * self.scale_factor) + end + if new_bb then + -- We made a new blitbuffer, we need to explicitely free -- the old one to not leak memory - local new_bb = self._bb:scale(w or native_w, h or native_h) if self._bb_disposable then self._bb:free() end self._bb = new_bb self._bb_disposable = true -- new object will have to be freed + bb_w, bb_h = self._bb:getWidth(), self._bb:getHeight() + end + + -- deal with positionning + if self.width and self.height then + -- if image is bigger than paint area, allow center_ratio variation + -- around 0.5 so we can pan till image border + if bb_w > self.width then + self._max_off_center_x_ratio = 0.5 - self.width/2 / bb_w + end + if bb_h > self.height then + self._max_off_center_y_ratio = 0.5 - self.height/2 / bb_h + end + -- correct provided center ratio if out limits + if self.center_x_ratio < 0.5 - self._max_off_center_x_ratio then + self.center_x_ratio = 0.5 - self._max_off_center_x_ratio + elseif self.center_x_ratio > 0.5 + self._max_off_center_x_ratio then + self.center_x_ratio = 0.5 + self._max_off_center_x_ratio + end + if self.center_y_ratio < 0.5 - self._max_off_center_y_ratio then + self.center_y_ratio = 0.5 - self._max_off_center_y_ratio + elseif self.center_y_ratio > 0.5 + self._max_off_center_y_ratio then + self.center_y_ratio = 0.5 + self._max_off_center_y_ratio + end + -- set offsets to reflect center ratio, whether oversized or not + self._offset_x = self.center_x_ratio * bb_w - self.width/2 + self._offset_y = self.center_y_ratio * bb_h - self.height/2 + logger.dbg("ImageWidget: initial offsets", self._offset_x, self._offset_y) end + + -- store final bb's width and height + self._bb_w = bb_w + self._bb_h = bb_h end function ImageWidget:getSize() self:_render() - return Geom:new{ w = self._bb:getWidth(), h = self._bb:getHeight() } + -- getSize will be used by the widget stack for centering/padding + if not self.width or not self.height then + -- no width/height provided, return bb size to let widget stack do the centering + return Geom:new{ w = self._bb:getWidth(), h = self._bb:getHeight() } + end + -- if width or height provided, return them as is, even if image is smaller + -- and would be centered: we'll do the centering ourselves with offsets + return Geom:new{ w = self.width, h = self.height } end -function ImageWidget:rotate(degree) - self:_render() - self._bb:rotate(degree) +function ImageWidget:getScaleFactor() + -- return computed scale_factor, useful if 0 (scale to fit) was used + return self.scale_factor +end + +function ImageWidget:getPanByCenterRatio(x, y) + -- returns center ratio (without limits check) we would get with this panBy + local center_x_ratio = (x + self._offset_x + self.width/2) / self._bb_w + local center_y_ratio = (y + self._offset_y + self.height/2) / self._bb_h + return center_x_ratio, center_y_ratio +end + +function ImageWidget:panBy(x, y) + -- update center ratio from new offset + self.center_x_ratio = (x + self._offset_x + self.width/2) / self._bb_w + self.center_y_ratio = (y + self._offset_y + self.height/2) / self._bb_h + -- correct new center ratio if out limits + if self.center_x_ratio < 0.5 - self._max_off_center_x_ratio then + self.center_x_ratio = 0.5 - self._max_off_center_x_ratio + elseif self.center_x_ratio > 0.5 + self._max_off_center_x_ratio then + self.center_x_ratio = 0.5 + self._max_off_center_x_ratio + end + if self.center_y_ratio < 0.5 - self._max_off_center_y_ratio then + self.center_y_ratio = 0.5 - self._max_off_center_y_ratio + elseif self.center_y_ratio > 0.5 + self._max_off_center_y_ratio then + self.center_y_ratio = 0.5 + self._max_off_center_y_ratio + end + -- new offsets that reflect this new center ratio + local new_offset_x = self.center_x_ratio * self._bb_w - self.width/2 + local new_offset_y = self.center_y_ratio * self._bb_h - self.height/2 + -- only trigger screen refresh it we actually pan + if new_offset_x ~= self._offset_x or new_offset_y ~= self._offset_y then + self._offset_x = new_offset_x + self._offset_y = new_offset_y + UIManager:setDirty("all", function() + return "partial", self.dimen + end) + end + -- return new center ratio, so caller can use them later to create a new + -- ImageWidget with a different scale_factor, while keeping center point + return self.center_x_ratio, self.center_y_ratio end function ImageWidget:paintTo(bb, x, y) if self.hide then return end - -- self:_reader is called in getSize method + -- self:_render is called in getSize method local size = self:getSize() self.dimen = Geom:new{ x = x, y = y, w = size.w, h = size.h } + logger.dbg("blitFrom", x, y, self._offset_x, self._offset_y, size.w, size.h) if self.alpha == true then - bb:alphablitFrom(self._bb, x, y, 0, 0, size.w, size.h) + bb:alphablitFrom(self._bb, x, y, self._offset_x, self._offset_y, size.w, size.h) else - bb:blitFrom(self._bb, x, y, 0, 0, size.w, size.h) + bb:blitFrom(self._bb, x, y, self._offset_x, self._offset_y, size.w, size.h) end if self.invert then bb:invertRect(x, y, size.w, size.h) @@ -208,6 +340,10 @@ function ImageWidget:free() self._bb:free() self._bb = nil end + -- reset self.scale_factor to its initial value, in case + -- self._render() is called again (happens with iconbutton, + -- avoids x2 x2 x2 if high dpi and icon scaled x8 after 3 calls) + self.scale_factor = self._initial_scale_factor end function ImageWidget:onCloseWidget() diff --git a/plugins/goodreads.koplugin/goodreadsbook.lua b/plugins/goodreads.koplugin/goodreadsbook.lua index f2cd0291e..130f13e39 100644 --- a/plugins/goodreads.koplugin/goodreadsbook.lua +++ b/plugins/goodreads.koplugin/goodreadsbook.lua @@ -200,14 +200,12 @@ function GoodreadsBook:genBookInfoGroup() image = image.image_bb, width = img_width, height = img_height, - autoscale = false, }) else table.insert(book_info_group, ImageWidget:new{ file = "resources/goodreadsnophoto.png", width = img_width, height = img_height, - autoscale = false, }) end