ImageViewer: added zoom & pan via gestures

ImageWidget: removed 'autostretch' setting (replaced by scale_factor=0)
and renamed 'autoscale' setting to 'scale_for_dpi'.
pull/2522/head
poire-z 7 years ago committed by Qingping Hou
parent f402ee5f6f
commit 7166efd777

@ -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

@ -298,7 +298,6 @@ function BookStatusWidget:genBookInfoGroup()
image = self.thumbnail,
width = img_width,
height = img_height,
autoscale = false,
})
end

@ -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

@ -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

@ -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()

@ -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

Loading…
Cancel
Save