From 0ef948f60d1317b2f173ec168a11a3c44a035a54 Mon Sep 17 00:00:00 2001 From: poire-z Date: Tue, 16 Jan 2018 12:30:23 +0100 Subject: [PATCH] Trapper: adds dismissableRunInSubprocess() + new TrapWidget New TrapWidget: invisible full screen widget for catching UI events. Can optionally display a text message at bottom left of screen (ie: "Loading..."), used by default by Trapper:dismissable functions when no widget provided. Added Trapper:dismissableRunInSubprocess() to run a lua function in a sub-process, allowing it to be dismissed, and returns its return value(s). --- base | 2 +- frontend/ui/trapper.lua | 295 +++++++++++++++++++++++++++--- frontend/ui/uimanager.lua | 4 +- frontend/ui/widget/trapwidget.lua | 167 +++++++++++++++++ 4 files changed, 441 insertions(+), 27 deletions(-) create mode 100644 frontend/ui/widget/trapwidget.lua diff --git a/base b/base index feca07cc6..9fb7d4ae0 160000 --- a/base +++ b/base @@ -1 +1 @@ -Subproject commit feca07cc6f32271c9a904f67372719b13fe292d7 +Subproject commit 9fb7d4ae04d3869579c16a2dc504a69b99e6295b diff --git a/frontend/ui/trapper.lua b/frontend/ui/trapper.lua index f69f685f4..d374f9e02 100644 --- a/frontend/ui/trapper.lua +++ b/frontend/ui/trapper.lua @@ -11,8 +11,10 @@ Mostly done with coroutines, but hides their usage for simplicity. local ConfirmBox = require("ui/widget/confirmbox") local InfoMessage = require("ui/widget/infomessage") +local TrapWidget = require("ui/widget/trapwidget") local UIManager = require("ui/uimanager") local ffiutil = require("ffi/util") +local dump = require("dump") local logger = require("logger") local _ = require("gettext") @@ -307,10 +309,11 @@ Notes and limitations: 2) `cmd` needs to output something (we will wait till some data is available) If there are chances for it to not output anything, append `"; echo"` to `cmd` -3) We need an @{ui.widget.infomessage|InfoMessage}, that, as a modal, will catch - any @{ui.event|Tap event} happening during `cmd` execution. This can be - provided as a string (a new InfoMessage will be created), or can be an - existing already displayed InfoMessage. +3) We need a @{ui.widget.trapwidget|TrapWidget} or @{ui.widget.infomessage|InfoMessage}, + that, as a modal, will catch any @{ui.event|Tap event} happening during + `cmd` execution. This can be an existing already displayed widget, or + provided as a string (a new TrapWidget will be created). If nil, an invisible + TrapWidget will be used instead. If we really need to have more control, we would need to use `select()` via `ffi` or do low level non-blocking reading on the file descriptor. @@ -319,12 +322,11 @@ collect indefinitely, the best option would be to compile any `timeout.c` and use it as a wrapper. @string cmd shell `cmd` to execute and get output from -@param infomessage string or already shown @{ui.widget.infomessage|InfoMessage} widget instance -@int check_interval_sec[opt=0.1] float interval in second for checking pipe for available output +@param trap_widget_or_string already shown widget, string or nil @treturn boolean completed (`true` if not interrupted, `false` if dismissed) @treturn string output of command ]] -function Trapper:dismissablePopen(cmd, infomessage, check_interval_sec) +function Trapper:dismissablePopen(cmd, trap_widget_or_string) local _coroutine = coroutine.running() -- assert(_coroutine ~= nil, "Need to be called from a coroutine") if not _coroutine then @@ -338,34 +340,58 @@ function Trapper:dismissablePopen(cmd, infomessage, check_interval_sec) return false end - local own_infomessage = false - if type(infomessage) == "string" then - infomessage = InfoMessage:new{text = infomessage} - UIManager:show(infomessage) - UIManager:forceRePaint() - own_infomessage = true + local trap_widget + local own_trap_widget = false + local own_trap_widget_invisible = false + if type(trap_widget_or_string) == "table" then + -- Assume it is a usable already displayed trap'able widget with + -- a dismiss_callback (ie: InfoMessage or TrapWidget) + trap_widget = trap_widget_or_string + else + if type(trap_widget_or_string) == "string" then + -- Use a TrapWidget with this as text + trap_widget = TrapWidget:new{ + text = trap_widget_or_string, + } + UIManager:show(trap_widget) + UIManager:forceRePaint() + else + -- Use an invisible TrapWidget that resend event + trap_widget = TrapWidget:new{ + text = nil, + resend_event = true, + } + UIManager:show(trap_widget) + own_trap_widget_invisible = true + end + own_trap_widget = true end - infomessage.dismiss_callback = function() + trap_widget.dismiss_callback = function() -- this callback will resume us at coroutine.yield() below -- with a go_on = false coroutine.resume(_coroutine, false) end - if not check_interval_sec then - check_interval_sec = 0.1 -- default: check for output every 100ms - end local collect_interval_sec = 5 -- collect cancelled cmd every 5 second, no hurry + local check_interval_sec = 0.125 -- start with checking for output every 125ms + local check_num = 0 + local completed = false local output = nil local std_out = io.popen(cmd, "r") if std_out then - -- We check regularly if data is available to be read, and we give - -- control in the meantime to UIManager so our InfoMessage.dismiss_callback + -- We check regularly if data is available to be read, and we give control + -- in the meantime to UIManager so our trap_widget's dismiss_callback -- get a chance to be triggered, in which case we won't wait for reading, -- We'll schedule a background function to collect the uneeded output and -- close the pipe later. while true do + -- Every 10 iterations, increase interval until a max of 1 sec is reached + check_num = check_num + 1 + if check_interval_sec < 1 and check_num % 10 == 0 then + check_interval_sec = math.min(check_interval_sec * 2, 1) + end -- The following function will resume us at coroutine.yield() below -- with a go_on = true local go_on_func = function() coroutine.resume(_coroutine, true) end @@ -390,7 +416,8 @@ function Trapper:dismissablePopen(cmd, infomessage, check_interval_sec) UIManager:scheduleIn(collect_interval_sec, collect_and_clean) break end - -- the go_on_func resumed us, check if pipe is ready to be read + -- The go_on_func resumed us: we have not been dismissed. + -- Check if pipe is ready to be read if ffiutil.getNonBlockingReadSize(std_out) ~= 0 then -- Some data is available for reading: read it all, -- but we may block from now on @@ -402,13 +429,233 @@ function Trapper:dismissablePopen(cmd, infomessage, check_interval_sec) -- logger.dbg("no cmd output yet, will check again soon") end end - if own_infomessage then - -- Remove our own infomessage - UIManager:close(infomessage) - UIManager:forceRePaint() + if own_trap_widget then + -- Remove our own trap_widget + UIManager:close(trap_widget) + if not own_trap_widget_invisible then + UIManager:forceRePaint() + end end -- return what we got or not to our caller return completed, output end +--[[-- +Run a function (task) in a sub-process, allowing it to be dismissed, +and returns its return value(s). + +Notes and limitations: + +1) As function is run in a sub-process, it can't modify the main + KOReader process (its parent). It has access to the state of + KOReader at the time the sub-process was started. It should not + use any service/driver that would make the parent process vision + of the device state incoherent (ie: it should not use UIManager, + display widgets, change settings, enable wifi...). + It is allowed to modify the filesystem, as long as KOreader + has not a cached vision of this filesystem part. + Its returned value(s) are returned to the parent. + +2) task may return complex data structures (but with simple lua types, + no function) or a single string. If task returns a string or nil, + set task_returns_simple_string to true, allowing for some + optimisations to be made. + +3) If dismissed, the sub-process is killed with SIGKILL, and + task is aborted without any chance for cleanup work: use of temporary + files should so be limited (do some cleanup of dirty files from + previous aborted executions at the start of each new execution if + needed), and try to keep important operations as atomic as possible. + +4) We need a @{ui.widget.trapwidget|TrapWidget} or @{ui.widget.infomessage|InfoMessage}, + that, as a modal, will catch any @{ui.event|Tap event} happening during + `cmd` execution. This can be an existing already displayed widget, or + provided as a string (a new TrapWidget will be created). If nil, an invisible + TrapWidget will be used instead. + +@function task lua function to execute and get return values from +@param trap_widget_or_string already shown widget, string or nil +@boolean task_returns_simple_string[opt=false] true if task returns a single string +@treturn boolean completed (`true` if not interrupted, `false` if dismissed) +@return ... return values of task +]] +function Trapper:dismissableRunInSubprocess(task, trap_widget_or_string, task_returns_simple_string) + local _coroutine = coroutine.running() + if not _coroutine then + logger.warn("unwrapped dismissableRunInSubprocess(), falling back to blocking in-process run") + return true, task() + end + + local trap_widget + local own_trap_widget = false + local own_trap_widget_invisible = false + if type(trap_widget_or_string) == "table" then + -- Assume it is a usable already displayed trap'able widget with + -- a dismiss_callback (ie: InfoMessage or TrapWidget) + trap_widget = trap_widget_or_string + else + if type(trap_widget_or_string) == "string" then + -- Use a TrapWidget with this as text + trap_widget = TrapWidget:new{ + text = trap_widget_or_string, + } + UIManager:show(trap_widget) + UIManager:forceRePaint() + else + -- Use an invisible TrapWidget that resend event + trap_widget = TrapWidget:new{ + text = nil, + resend_event = true, + } + UIManager:show(trap_widget) + own_trap_widget_invisible = true + end + own_trap_widget = true + end + trap_widget.dismiss_callback = function() + -- this callback will resume us at coroutine.yield() below + -- with a go_on = false + coroutine.resume(_coroutine, false) + end + + local collect_interval_sec = 5 -- collect cancelled cmd every 5 second, no hurry + local check_interval_sec = 0.125 -- start with checking for output every 125ms + local check_num = 0 + + local completed = false + local ret_values = nil + + local pid, parent_read_fd = ffiutil.runInSubProcess(function(pid, child_write_fd) + local output_str = "" + if task_returns_simple_string then + -- task is assumed to return only a string or nil, avoid + -- possibly expensive dump()/dofile() + local result = task() + if type(result) == "string" then + output_str = result + elseif result ~= nil then + logger.warn("returned value from task is not a string:", result) + end + else + -- task may return complex data structures, that we dump() + -- Note: be sure these data structures contain only classic types, + -- and no function (dofile() with fail if it meets + -- "function: 0x55949671c670"...) + -- task may also return multiple return values, so we + -- wrap them in a table (beware the { } construct may stop + -- at the first nil met) + local results = { task() } + output_str = "return "..dump(results).."\n" + end + ffiutil.writeToFD(child_write_fd, output_str, true) + end, true) -- with_pipe = true + + if pid then + -- We check regularly if subprocess is done, and we give control + -- in the meantime to UIManager so our trap_widget's dismiss_callback + -- get a chance to be triggered, in which case we'll terminate the + -- subprocess and schedule a background function to collect it. + while true do + -- Every 10 iterations, increase interval until a max of 1 sec is reached + check_num = check_num + 1 + if check_interval_sec < 1 and check_num % 10 == 0 then + check_interval_sec = math.min(check_interval_sec * 2, 1) + end + -- The following function will resume us at coroutine.yield() below + -- with a go_on = true + local go_on_func = function() coroutine.resume(_coroutine, true) end + UIManager:scheduleIn(check_interval_sec, go_on_func) -- called in 100ms by default + local go_on = coroutine.yield() -- gives control back to UIManager + if not go_on then -- the dismiss_callback resumed us + UIManager:unschedule(go_on_func) + -- We kill and forget the sub-process here, but something has + -- to collect it so it does not become a zombie + ffiutil.terminateSubProcess(pid) + local collect_and_clean + collect_and_clean = function() + if ffiutil.isSubProcessDone(pid) then + if parent_read_fd then + ffiutil.readAllFromFD(parent_read_fd) -- close it + end + logger.dbg("collected previously dismissed subprocess") + else + if parent_read_fd and ffiutil.getNonBlockingReadSize(parent_read_fd) ~= 0 then + -- If subprocess started outputing to fd, read from it, + -- so its write() stops blocking and subprocess can exit + ffiutil.readAllFromFD(parent_read_fd) + -- We closed our fd, don't try again to read or close it + parent_read_fd = nil + end + -- reschedule to collect it + UIManager:scheduleIn(collect_interval_sec, collect_and_clean) + logger.dbg("previously dismissed subprocess not yet collectable") + end + end + UIManager:scheduleIn(collect_interval_sec, collect_and_clean) + break + end + -- The go_on_func resumed us: we have not been dismissed. + -- Check if sub process has ended + -- Depending on the the size of what the child has to write, + -- it may has ended (if data fits in the kernel pipe buffer) or + -- it may still be alive blocking on write() (if data exceeds + -- the kernel pipe buffer) + local subprocess_done = ffiutil.isSubProcessDone(pid) + local stuff_to_read = parent_read_fd and ffiutil.getNonBlockingReadSize(parent_read_fd) ~=0 + logger.dbg("subprocess_done:", subprocess_done, " stuff_to_read:", stuff_to_read) + if subprocess_done or stuff_to_read then + -- Subprocess is gone or nearly gone + completed = true + if stuff_to_read then + local ret_str = ffiutil.readAllFromFD(parent_read_fd) + if task_returns_simple_string then + ret_values = { ret_str } + else + local ok, results = pcall(load(ret_str)) + if ok and results then + ret_values = results + else + logger.warn("load() failed:", results) + end + end + if not subprocess_done then + -- We read the output while process was still alive. + -- It may be dead now, or it may exit soon, and we + -- need to collect it. + -- Schedule that in 1 second (it should be dead), so + -- we can return our result now. + local collect_and_clean + collect_and_clean = function() + if ffiutil.isSubProcessDone(pid) then + logger.dbg("collected subprocess") + else -- reschedule + UIManager:scheduleIn(1, collect_and_clean) + logger.dbg("subprocess not yet collectable") + end + end + UIManager:scheduleIn(1, collect_and_clean) + end + else -- subprocess_done: process exited with no output + ffiutil.readAllFromFD(parent_read_fd) -- close our fd + -- no ret_values + end + break + end + logger.dbg("process not yet done, will check again soon") + end + end + if own_trap_widget then + -- Remove our own trap_widget + UIManager:close(trap_widget) + if not own_trap_widget_invisible then + UIManager:forceRePaint() + end + end + -- return what we got or not to our caller + if ret_values then + return completed, unpack(ret_values) + end + return completed +end + return Trapper diff --git a/frontend/ui/uimanager.lua b/frontend/ui/uimanager.lua index a516a05fc..1bb787109 100644 --- a/frontend/ui/uimanager.lua +++ b/frontend/ui/uimanager.lua @@ -252,7 +252,7 @@ function UIManager:close(widget, refreshtype, refreshregion) Input.disable_double_tap = false end end - if dirty then + if dirty and not widget.invisible then -- schedule remaining widgets to be painted for i = 1, #self._window_stack do self:setDirty(self._window_stack[i].widget) @@ -368,7 +368,7 @@ function UIManager:setDirty(widget, refreshtype, refreshregion) for i = 1, #self._window_stack do self._dirty[self._window_stack[i].widget] = true end - else + elseif not widget.invisible then self._dirty[widget] = true end end diff --git a/frontend/ui/widget/trapwidget.lua b/frontend/ui/widget/trapwidget.lua new file mode 100644 index 000000000..a00162e9d --- /dev/null +++ b/frontend/ui/widget/trapwidget.lua @@ -0,0 +1,167 @@ +--[[-- +Invisible full screen widget for catching UI events. +(for use with or by Trapper to interrupt its processing). + +Can optionally display a text message at bottom left of screen +(ie: "Loading…") +]] + + +local Blitbuffer = require("ffi/blitbuffer") +local BottomContainer = require("ui/widget/container/bottomcontainer") +local CenterContainer = require("ui/widget/container/centercontainer") +local Device = require("device") +local Event = require("ui/event") +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 LeftContainer = require("ui/widget/container/leftcontainer") +local RenderText = require("ui/rendertext") +local Size = require("ui/size") +local TextBoxWidget = require("ui/widget/textboxwidget") +local TextWidget = require("ui/widget/textwidget") +local UIManager = require("ui/uimanager") +local Input = Device.input +local Screen = Device.screen + +local TrapWidget = InputContainer:new{ + modal = true, + dismiss_callback = function() end, + text = nil, -- will be invisible if no message given + face = Font:getFace("infofont"), + -- Whether to resend the event caught and used for dismissal + resend_event = false, +} + +function TrapWidget:init() + local full_screen = Geom:new{ + x = 0, y = 0, + w = Screen:getWidth(), + h = Screen:getHeight(), + } + if Device:hasKeys() then + self.key_events = { + AnyKeyPressed = { { Input.group.Any }, + seqtext = "any key", doc = "dismiss" } + } + end + if Device:isTouchDevice() then + self.ges_events.TapDismiss = { + GestureRange:new{ ges = "tap", range = full_screen, } + } + self.ges_events.HoldDismiss = { + GestureRange:new{ ges = "hold", range = full_screen, } + } + self.ges_events.SwipeDismiss = { + GestureRange:new{ ges = "swipe", range = full_screen, } + } + end + if self.text then + local textw + -- Don't make our message reach full screen width, so + -- it looks like popping from bottom left corner + local tsize = RenderText:sizeUtf8Text(0, Screen:getWidth(), self.face, self.text) + if tsize.x < Screen:getWidth() * 0.9 then + textw = TextWidget:new{ + text = self.text, + face = self.face, + } + else -- if text too wide, use TextBoxWidget for multi lines display + textw = TextBoxWidget:new{ + text = self.text, + face = self.face, + width = math.floor(Screen:getWidth() * 0.9) + } + end + local border_size = Size.border.default + self.frame = FrameContainer:new{ + background = Blitbuffer.COLOR_WHITE, + bordersize = border_size, + margin = 0, + padding = 0, + padding_left = Size.padding.default, + padding_right = Size.padding.default, + textw, + } + -- To have our frame message a bit prettier with its left + -- and bottom borders not displayed, we make use of this + -- combination of Containers to push them off-screen + self[1] = CenterContainer:new{ + dimen = full_screen:copy(), + BottomContainer:new{ + dimen = Geom:new{ + w = full_screen.w, + h = full_screen.h + 2*border_size, + }, + LeftContainer:new{ + dimen = Geom:new{ + w = full_screen.w + 2*border_size, + h = self.frame:getSize().h, + }, + self.frame, + } + } + } + else + -- So that UIManager knows no refresh is needed and + -- avoids some unnecessary refreshes + self.invisible = true + end +end + +function TrapWidget:_dismissAndResent(evtype, ev) + self.dismiss_callback() + UIManager:close(self) + if self.resend_event and evtype and ev then + -- XXX There may be timing problems that could cause crashes, as we + -- use nextTick, if the dismiss_callback uses UIManager:scheduleIn() + -- or has set up some widget that may catch that event while not being + -- yet fully initialiazed. + -- It happened mostly when I had some bug somewhere, and it was a quite + -- reliable sign of a bug somewhere, but the stacktrace was unrelated + -- to the bug location. + -- Fix to avoid crashes: in GestureRange:match(), check that self.range() + -- does not return nil before using it: + -- if not range or not range:contains(gs.pos) then return false + UIManager:nextTick(function() UIManager:handleInputEvent(Event:new(evtype, ev)) end) + end + return true +end + +function TrapWidget:onAnyKeyPressed(_, ev) + return self:_dismissAndResent("KeyPress", ev) +end + +function TrapWidget:onTapDismiss(_, ev) + return self:_dismissAndResent("Gesture", ev) +end + +function TrapWidget:onHoldDismiss(_, ev) + return self:_dismissAndResent("Gesture", ev) +end + +function TrapWidget:onSwipeDismiss(_, ev) + return self:_dismissAndResent("Gesture", ev) +end + +function TrapWidget:onShow() + if self.frame then + UIManager:setDirty(self, function() + return "ui", self.frame.dimen + end) + end + return true +end + +function TrapWidget:onCloseWidget() + if self.frame then + UIManager:setDirty(nil, function() + return "ui", self.frame.dimen + end) + end + return true +end + +return TrapWidget