2
0
mirror of https://github.com/koreader/koreader synced 2024-11-18 03:25:46 +00:00
koreader/frontend/ui/widget/imageviewer.lua
poire-z 95b35ec669 Allow any multiswipe to close some fullscreen widgets
For consistency with BookMap and PageBrowser widgets
where swipe south (the usual gesture to quick close)
can't be used for closing and we had to use any
multiswipe instead, allow any multiswipe to close
these other fullscreen widgets too:
Menu (ToC, Bookmarks), KeyValuePage, ImageViewer,
BookStatusWidget, ReaderProgress, CalendarView.
2022-01-25 21:33:19 +01:00

727 lines
27 KiB
Lua

--[[--
ImageViewer displays an image with some simple manipulation options.
]]
local BD = require("ui/bidi")
local Blitbuffer = require("ffi/blitbuffer")
local ButtonTable = require("ui/widget/buttontable")
local CenterContainer = require("ui/widget/container/centercontainer")
local DataStorage = require("datastorage")
local Device = require("device")
local Event = require("ui/event")
local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local FrameContainer = require("ui/widget/container/framecontainer")
local ImageWidget = require("ui/widget/imagewidget")
local InputContainer = require("ui/widget/container/inputcontainer")
local ProgressWidget = require("ui/widget/progresswidget")
local Size = require("ui/size")
local TitleBar = require("ui/widget/titlebar")
local VerticalGroup = require("ui/widget/verticalgroup")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local UIManager = require("ui/uimanager")
local logger = require("logger")
local _ = require("gettext")
local Screen = Device.screen
local ImageViewer = InputContainer:new{
-- Allow for providing same different input types as ImageWidget :
-- a path to a file
file = nil,
-- or an already made BlitBuffer (ie: made by Mupdf.renderImageFile())
image = nil,
-- whether the provided BlitBuffer should be free'd. Usually true,
-- unless our caller wants to reuse the image it provided
image_disposable = true,
-- 'image' can alternatively be a table (list) of multiple BlitBuffers
-- (or functions returning BlitBuffers).
-- The table will have its .free() called onClose according to
-- the image_disposable provided here.
-- Each BlitBuffer in the table (or returned by functions) will be free'd
-- if the table itself has an image_disposable field set to true.
-- With images list, when switching image, whether to keep previous
-- image pan & zoom
images_keep_pan_and_zoom = true,
fullscreen = false, -- false will add some padding around widget (so footer can be visible)
with_title_bar = true,
title_text = _("Viewing image"), -- default title text
-- A caption can be toggled with tap on title_text (so, it needs with_title_bar=true):
caption = nil,
caption_visible = true, -- caption visible by default
-- Start with buttons hidden (tap on screen will toggle their visibility)
buttons_visible = false,
width = nil,
height = nil,
scale_factor = 0, -- start with image scaled for best fit
rotated = false,
image_padding = Size.margin.small,
button_padding = Size.padding.default,
-- sensitivity for hold (trigger full refresh) vs pan (move image)
pan_threshold = Screen:scaleBySize(5),
_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,
_images_list = nil,
_images_list_disposable = nil,
}
function ImageViewer:init()
if Device:hasKeys() then
self.key_events = {
Close = { {Device.input.group.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(),
}
local diagonal = math.sqrt( math.pow(Screen:getWidth(), 2) + math.pow(Screen:getHeight(), 2) )
self.ges_events = {
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 } },
-- Allow saving the image as Screenshoter does (with two fingers tap,
-- swipe being reserved for panning - on non multitouch Devices, this
-- is also available with a tap in the bottom left corner)
TapDiagonal = { GestureRange:new{ ges = "two_finger_tap",
scale = {diagonal - Screen:scaleBySize(200), diagonal}, rate = 1.0,
}
},
-- Allow closing with any multiswipe
MultiSwipe = { GestureRange:new{ ges = "multiswipe", range = range } },
}
end
if self.fullscreen then
self.covers_fullscreen = true -- hint for UIManager:_repaint()
end
-- if self.image is a list of images, swap it with first image to be displayed
if type(self.image) == "table" then
self._images_list = self.image
self.image = self._images_list[1]
if type(self.image) == "function" then
self.image = self.image()
end
self._images_list_cur = 1
self._images_list_nb = #self._images_list
self._images_orig_scale_factor = self.scale_factor
-- also swap disposable status
self._images_list_disposable = self.image_disposable
self.image_disposable = self._images_list.image_disposable
end
-- Widget layout
if self._scale_to_fit == nil then -- initialize our toggle
self._scale_to_fit = self.scale_factor == 0
end
self.align = "center"
self.region = Geom:new{
x = 0, y = 0,
w = Screen:getWidth(),
h = Screen:getHeight(),
}
if self.fullscreen then
self.height = Screen:getHeight()
self.width = Screen:getWidth()
else
self.height = Screen:getHeight() - Screen:scaleBySize(40)
self.width = Screen:getWidth() - Screen:scaleBySize(40)
end
-- Build all the widgets we may have to show
local buttons = {
{
{
id = "scale",
text = self._scale_to_fit and _("Original size") or _("Scale"),
callback = function()
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,
},
{
id = "rotate",
text = self.rotated and _("No rotation") or _("Rotate"),
callback = function()
self.rotated = not self.rotated and true or false
self:update()
end,
},
{
id = "close",
text = _("Close"),
callback = function()
self:onClose()
end,
},
},
}
self.button_table = ButtonTable:new{
width = self.width - 2*self.button_padding,
button_font_face = "cfont",
button_font_size = 20,
buttons = buttons,
zero_sep = true,
show_parent = self,
}
self.button_container = CenterContainer:new{
dimen = Geom:new{
w = self.width,
h = self.button_table:getSize().h,
},
self.button_table,
}
if self.with_title_bar then
-- (We don't provide fullscreen=true so to use the non-fullscreen smaller font size)
if self.caption then
-- Toggling caption will have us swap these two title bars
self.title_bar = TitleBar:new{ -- when caption hidden
width = self.width,
align = "left",
title = self.title_text,
title_multilines = true,
with_bottom_line = true,
left_icon = "triangle",
left_icon_rotation_angle = BD.mirroredUILayout() and 90 or 270,
left_icon_tap_callback = function()
self.caption_visible = not self.caption_visible
self:update()
end,
close_callback = function() self:onClose() end,
show_parent = self,
}
self.captioned_title_bar = TitleBar:new{ -- when caption shown
width = self.width,
align = "left",
title = self.title_text,
title_multilines = true,
subtitle = self.caption,
subtitle_multilines = true,
subtitle_fullwidth = true,
with_bottom_line = true,
left_icon = "triangle",
left_icon_rotation_angle = 180,
left_icon_tap_callback = function()
self.caption_visible = not self.caption_visible
self:update()
end,
close_callback = function() self:onClose() end,
show_parent = self,
}
else
self.title_bar = TitleBar:new{
width = self.width,
align = "left",
title = self.title_text,
title_multilines = true,
with_bottom_line = true,
close_callback = function() self:onClose() end,
show_parent = self,
}
end
end
if self._images_list then
-- progress bar
local percent = 1
if self._images_list and self._images_list_nb > 1 then
percent = (self._images_list_cur - 1) / (self._images_list_nb - 1)
end
self.progress_bar = ProgressWidget:new{
width = self.width - 2*self.button_padding,
height = Screen:scaleBySize(5),
percentage = percent,
margin_h = 0,
margin_v = 0,
radius = 0,
ticks = nil,
last = nil,
}
self.progress_container = CenterContainer:new{
dimen = Geom:new{
w = self.width,
h = self.progress_bar:getSize().h + Size.padding.small,
},
self.progress_bar
}
end
-- Container for the above elements, that we will reset and refill
self.frame_elements = VerticalGroup:new{ align = "left" }
self.main_frame = FrameContainer:new{
radius = not self.fullscreen and 8 or nil,
padding = 0,
margin = 0,
background = Blitbuffer.COLOR_WHITE,
self.frame_elements,
}
self[1] = WidgetContainer:new{
align = self.align,
dimen = self.region,
self.main_frame,
}
self:update()
end
function ImageViewer:update()
-- Free our ImageWidget, which is the only thing we'll replace (we reuse
-- all the other text widgets and containers)
self:_clean_image_wg()
-- Update window geometry (fullscreen can be toggled, but without any title
-- and buttons, which allow us to not have to update their width)
local orig_dimen = self.main_frame.dimen
if self.fullscreen then
self.height = Screen:getHeight()
self.width = Screen:getWidth()
else
self.height = Screen:getHeight() - Screen:scaleBySize(40)
self.width = Screen:getWidth() - Screen:scaleBySize(40)
end
-- Remove elements (not freeing them) from frame_elements
while table.remove(self.frame_elements) do end
self.frame_elements:resetLayout()
-- And put back those that we should show
-- Title bar
if self.with_title_bar then
if self.caption and self.caption_visible then
table.insert(self.frame_elements, self.captioned_title_bar)
else
table.insert(self.frame_elements, self.title_bar)
end
end
-- Image container (we'll insert it once all others are added and we know the height remaining)
local image_container_idx = #self.frame_elements + 1
-- Progress bar
if self._images_list then
local percent = 1
if self._images_list_nb > 1 then
percent = (self._images_list_cur - 1) / (self._images_list_nb - 1)
end
self.progress_bar:setPercentage(percent)
table.insert(self.frame_elements, self.progress_container)
end
-- Bottom buttons
if self.buttons_visible then
local scale_btn = self.button_table:getButtonById("scale")
scale_btn:setText(self._scale_to_fit and _("Original size") or _("Scale"), scale_btn.width)
local rotate_btn = self.button_table:getButtonById("rotate")
rotate_btn:setText(self.rotated and _("No rotation") or _("Rotate"), rotate_btn.width)
table.insert(self.frame_elements, self.button_container)
end
-- Get the available height and update the image widget itself
self.img_container_h = self.height - self.frame_elements:getSize().h
self:_new_image_wg()
-- Insert image widget in our vertical group
table.insert(self.frame_elements, image_container_idx, self.image_container)
self.frame_elements:resetLayout()
self.main_frame.radius = not self.fullscreen and 8 or nil
-- NOTE: We use UI instead of partial, because we do NOT want to end up using a REAGL waveform...
-- NOTE: Disabling dithering here makes for a perfect test-case of how well it works:
-- page turns will show color quantization artefacts (i.e., banding) like crazy,
-- while a long touch will trigger a dithered, flashing full-refresh that'll make everything shiny :).
self.dithered = true
UIManager:setDirty(self, function()
local update_region = self.main_frame.dimen:combine(orig_dimen)
return "ui", update_region, true
end)
end
function ImageViewer:_clean_image_wg()
-- To be called before re-using / disposing of self._image_wg,
-- otherwise resources used by its blitbuffer won't be free'd
if self._image_wg then
logger.dbg("ImageViewer:_clean_image_wg")
self._image_wg:free()
self._image_wg = nil
end
end
-- Used in init & update to instantiate a new ImageWidget & its container
function ImageViewer:_new_image_wg()
-- If no buttons and no title are shown, use the full screen
local max_image_h = self.img_container_h
local max_image_w = self.width
-- Otherwise, add paddings around image
if self.buttons_visible or self.with_title_bar then
max_image_h = self.img_container_h - self.image_padding*2
max_image_w = self.width - self.image_padding*2
end
local rotation_angle = 0
if self.rotated then
-- in portrait mode, rotate according to this global setting so we are
-- like in landscape mode
-- NOTE: This is the sole user of this legacy global left!
local rotate_clockwise = DLANDSCAPE_CLOCKWISE_ROTATION
if Screen:getWidth() > Screen:getHeight() then
-- in landscape mode, counter-rotate landscape rotation so we are
-- back like in portrait mode
rotate_clockwise = not rotate_clockwise
end
rotation_angle = rotate_clockwise and 90 or 270
end
self._image_wg = ImageWidget:new{
file = self.file,
image = self.image,
image_disposable = false, -- we may re-use self.image
alpha = true, -- we might be showing images with an alpha channel (e.g., from Wikipedia)
width = max_image_w,
height = max_image_h,
rotation_angle = rotation_angle,
scale_factor = self.scale_factor,
center_x_ratio = self._center_x_ratio,
center_y_ratio = self._center_y_ratio,
}
self.image_container = CenterContainer:new{
dimen = Geom:new{
w = self.width,
h = self.img_container_h,
},
self._image_wg,
}
end
function ImageViewer:onShow()
self.dithered = true
UIManager:setDirty(self, function()
return "full", self.main_frame.dimen, true
end)
return true
end
function ImageViewer:switchToImageNum(image_num)
if self.image and self.image_disposable and self.image.free then
logger.dbg("ImageViewer:switchToImageNum: free self.image", self.image)
self.image:free()
self.image = nil
end
self.image = self._images_list[image_num]
if type(self.image) == "function" then
self.image = self.image()
end
self._images_list_cur = image_num
if not self.images_keep_pan_and_zoom then
self._center_x_ratio = 0.5
self._center_y_ratio = 0.5
self.scale_factor = self._images_orig_scale_factor
end
self:update()
end
function ImageViewer:onTap(_, ges)
if ges.pos:notIntersectWith(self.main_frame.dimen) then
self:onClose()
return true
end
if not Device:hasMultitouch() then
-- Allow saving screenshot with tap in bottom left corner
if not self.buttons_visible and ges.pos.x < Screen:getWidth()/10 and ges.pos.y > Screen:getHeight()*9/10 then
return self:onSaveImageView()
end
end
if self.with_title_bar then
-- Ignore tap in title/caption (button and caption toggler are managed by TitleBar itself),
-- the user is most probably trying to toggle caption, but failed hitting the toggle: don't
-- have this toggle bottom buttons.
if ges.pos.y < self.frame_elements[1]:getSize().h then
return true
end
end
if self._images_list then
-- If it's a list of image (e.g. animated gifs), tap left/right 1/3 of screen to navigate
local show_prev_image, show_next_image
if ges.pos.x < Screen:getWidth()/3 then
show_prev_image = not BD.mirroredUILayout()
show_next_image = BD.mirroredUILayout()
elseif ges.pos.x > Screen:getWidth()*2/3 then
show_prev_image = BD.mirroredUILayout()
show_next_image = not BD.mirroredUILayout()
end
if show_prev_image then
if self._images_list_cur > 1 then
self:switchToImageNum(self._images_list_cur - 1)
end
elseif show_next_image then
if self._images_list_cur < self._images_list_nb then
self:switchToImageNum(self._images_list_cur + 1)
end
else -- toggle buttons when tap on middle 1/3 of screen width
self.buttons_visible = not self.buttons_visible
self:update()
end
else
-- No image list: tap on any part of screen toggles buttons visibility
self.buttons_visible = not self.buttons_visible
self:update()
end
return true
end
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
if ges.pos.x < Screen:getWidth() * 1/16 or ges.pos.x > Screen:getWidth() * 15/16 then
-- allow for zooming with vertical swipe on screen sides
-- (for devices without multi touch where pinch and spread don't work)
local inc = ges.distance / Screen:getHeight()
self:onZoomIn(inc)
else
self:panBy(0, distance)
end
elseif direction == "south" then
if ges.pos.x < Screen:getWidth() * 1/16 or ges.pos.x > Screen:getWidth() * 15/16 then
-- allow for zooming with vertical swipe on screen sides
local dec = ges.distance / Screen:getHeight()
self:onZoomOut(dec)
elseif self.scale_factor == 0 then
-- When scaled to fit (on initial launch, or after one has tapped
-- "Scale"), as we are then sure that there is no use for panning,
-- allow swipe south to close the widget.
self:onClose()
else
self:panBy(0, -distance)
end
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:onMultiSwipe(_, ges)
-- As swipe south to close is only enabled when scaled to fit, but not
-- when we are zoomed in/out, allow any multiswipe to close.
self:onClose()
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 math.abs(self._pan_relative_x) < self.pan_threshold and math.abs(self._pan_relative_y) < self.pan_threshold then
-- Hold with no move (or less than pan_threshold): use this to trigger full refresh
self.dithered = true
UIManager:setDirty(nil, "full", nil, true)
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
function ImageViewer:onTapDiagonal()
return self:onSaveImageView()
end
function ImageViewer:onSaveImageView()
-- We save the currently displayed blitbuffer (panned or zoomed)
-- after getting fullscreen and removing UI elements if needed.
local restore_settings_func
if self.with_title_bar or self.buttons_visible or not self.fullscreen then
local with_title_bar = self.with_title_bar
local buttons_visible = self.buttons_visible
local fullscreen = self.fullscreen
restore_settings_func = function()
self.with_title_bar = with_title_bar
self.buttons_visible = buttons_visible
self.fullscreen = fullscreen
self:update()
end
self.with_title_bar = false
self.buttons_visible = false
self.fullscreen = true
self:update()
UIManager:forceRePaint()
end
local screenshots_dir = G_reader_settings:readSetting("screenshot_dir") or DataStorage:getDataDir() .. "/screenshots/"
local screenshot_name = os.date(screenshots_dir .. "ImageViewer" .. "_%Y-%m-%d_%H%M%S.png")
UIManager:sendEvent(Event:new("Screenshot", screenshot_name, restore_settings_func))
return true
end
function ImageViewer:onClose()
UIManager:close(self)
return true
end
function ImageViewer:onAnyKeyPressed()
self:onClose()
return true
end
function ImageViewer:onCloseWidget()
-- Our ImageWidget (self._image_wg) is always a proper child widget, so it'll receive this event,
-- and attempt to free its resources accordingly.
-- But, if it didn't have to touch the original BB (self.image) passed to ImageViewer (e.g., no scaling needed),
-- it will *re-use* self.image, and flag it as non-disposable, meaning it will not have been free'd earlier.
-- Since we're the ones who ultimately truly know whether we should dispose of self.image or not, do that now ;).
if self.image and self.image_disposable and self.image.free then
logger.dbg("ImageViewer:onCloseWidget: free self.image", self.image)
self.image:free()
self.image = nil
end
-- also clean _images_list if it provides a method for that
if self._images_list and self._images_list_disposable and self._images_list.free then
logger.dbg("ImageViewer:onCloseWidget: free self._images_list", self._images_list)
self._images_list:free()
end
-- Those, on the other hand, are always initialized, but may not actually be in our widget tree right now,
-- depending on what we needed to show, so they might not get sent a CloseWidget event.
-- They (and their FFI/C resources) would eventually get released by the GC, but let's be pedantic ;).
if self.with_title_bar then
self.title_bar:free()
if self.caption then
self.captioned_title_bar:free()
end
end
if self._images_list then
self.progress_container:free()
end
self.button_container:free()
-- NOTE: Assume there's no image beneath us, so, no dithering request
UIManager:setDirty(nil, function()
return "flashui", self.main_frame.dimen
end)
end
return ImageViewer