Files
bookorbit/koreader-plugin/bookorbit.koplugin/bookorbit_toast.lua
T
neon e28f45452d fix(koreader): keep each device's layout at every interface size
Layout breakpoints use the screen width at 1x, so Interface size scales text,
icons and spacing without dropping Home columns or detail panes. Every page,
sheet and widget picks a layout that fits instead of cutting text or leaving
gaps; search becomes balanced list-row sections, Edit Home keeps hidden widgets,
stock KOReader messages become BookOrbit toasts, and the plugin is 2.1.0.
2026-10-24 03:46:05 +00:00

219 lines
7.5 KiB
Lua

--[[--
BookOrbit's status pills: one black pill centred above the page's pager.
- Toast.show: a short message. A toast in KOReader's sense: any input closes
it and still reaches the page underneath.
- Toast.error: a failure, with Retry when there is something to retry.
Tapping the pill retries; tapping anywhere else only closes it.
- Toast.loading: what is loading while a request runs. It stands in for
KOReader's TrapWidget, so it is shown before Trapper takes it and any tap,
hold, swipe or key cancels the request through dismiss_callback.
The pill repaints only its own rectangle, so on e-ink it costs a small
partial refresh instead of a dialog's full one.
]]
local Device = require("device")
local Geom = require("ui/geometry")
local GestureRange = require("ui/gesturerange")
local InputContainer = require("ui/widget/container/inputcontainer")
local UIManager = require("ui/uimanager")
local _ = require("gettext")
local BoardMetrics = require("bookorbit_board_metrics")
local BoardUI = require("bookorbit_board_ui")
local Screen = Device.screen
local INK = BoardMetrics.INK
local px = BoardMetrics.px
local Toast = {}
local current = nil
-- Above the pager on BookOrbit pages and above KOReader's footer in a book.
local function bottomGap()
return px(64)
end
local function maxWidth()
local W = BoardMetrics.layoutW()
local mx = px(W >= 900 and 28 or W >= 700 and 24 or W >= 560 and 20 or 16)
return Screen:getWidth() - 2 * mx
end
-- The pill for the first of `variants` (lists of runs) that fits the screen.
local function buildPill(glyph, variants, light)
local h = px(44)
local fg = light and INK.black or INK.white
local max_w = maxWidth()
local chosen
for index, parts in ipairs(variants) do
local runs = {}
for _i, p in ipairs(parts) do
table.insert(runs, { t = p.t, b = p.b, col = p.col or fg })
end
local line = BoardUI.runs(runs, { s = 15 })
local w = line:getSize().w + px(14) + (glyph and (px(18) + px(10)) or 0) + px(18)
if w <= max_w or index == #variants then
if w > max_w then
line = BoardUI.runs(runs, { s = 15, w = max_w - (w - line:getSize().w), data = true })
w = max_w
end
chosen = { line = line, w = w }
break
end
if line.free then line:free() end
end
local items = { BoardUI.space(px(glyph and 14 or 18), 1) }
if glyph then
table.insert(items, BoardUI.icon(glyph, px(18), { sw = 2.5, col = light and "black" or "white" }))
table.insert(items, BoardUI.space(px(10), 1))
end
table.insert(items, chosen.line)
table.insert(items, BoardUI.flex(0))
local border = light and math.max(1, px(1.5)) or 0
return BoardUI.box({ w = chosen.w, h = h, radius = math.floor(h / 2), border = border, border_col = INK.black, bg = light and INK.white or INK.black },
BoardUI.hstack({ w = chosen.w - 2 * border, h = h - 2 * border }, items))
end
local Pill = InputContainer:extend{
name = "bookorbit_toast",
kind = "info",
glyph = nil,
variants = nil,
timeout = nil,
on_tap = nil,
dismiss_callback = nil,
}
function Pill:init()
self[1] = buildPill(self.glyph, self.variants, self.light)
local size = self[1]:getSize()
local x = math.floor((Screen:getWidth() - size.w) / 2)
local y = Screen:getHeight() - (self.bottom or bottomGap()) - size.h
self.dimen = Geom:new{ x = x, y = y, w = size.w, h = size.h }
self.toast = self.kind == "info"
self.modal = self.kind ~= "info"
local full = Geom:new{ x = 0, y = 0, w = Screen:getWidth(), h = Screen:getHeight() }
self.ges_events = {
TapPill = { GestureRange:new{ ges = "tap", range = full } },
HoldPill = { GestureRange:new{ ges = "hold", range = full } },
SwipePill = { GestureRange:new{ ges = "swipe", range = full } },
}
if Device:hasKeys() then
self.key_events = { AnyKeyPill = { { Device.input.group.Any } } }
end
end
function Pill:paintTo(bb, x, y)
self[1]:paintTo(bb, self.dimen.x, self.dimen.y)
end
function Pill:close()
if self.closed then return end
self.closed = true
if self.timer then UIManager:unschedule(self.timer) end
if current == self then current = nil end
UIManager:close(self, "ui", self.dimen)
end
function Pill:onCloseWidget()
if self.timer then UIManager:unschedule(self.timer) end
if current == self then current = nil end
if self[1] and self[1].free then self[1]:free() end
end
-- A toast closes on any input and lets it through; the others take it.
function Pill:handleInput(ges)
if self.kind == "info" then
self:close()
return false
end
if self.kind == "loading" then
if self.dismiss_callback then self.dismiss_callback() end
self:close()
return true
end
local on_pill = ges and ges.pos and ges.pos:intersectWith(self.dimen)
self:close()
if on_pill and self.on_tap then UIManager:nextTick(self.on_tap) end
return true
end
function Pill:onTapPill(_, ges) return self:handleInput(ges) end
function Pill:onHoldPill(_, ges) return self:handleInput(ges) end
function Pill:onSwipePill(_, ges) return self:handleInput(nil) end
function Pill:onAnyKeyPill() return self:handleInput(nil) end
function Pill:onGesture(ev)
-- A toast must not stop the page from getting the same gesture.
if self.toast then
self:close()
return false
end
return InputContainer.onGesture(self, ev)
end
local function show(pill)
if current and not current.closed and current.kind ~= "loading" then current:close() end
current = pill
UIManager:show(pill, "ui", pill.dimen)
if pill.timeout then
pill.timer = function() pill:close() end
UIManager:scheduleIn(pill.timeout, pill.timer)
end
return pill
end
-- A short message. o: icon (default check), timeout (s), bottom (px).
function Toast.show(str, o)
o = o or {}
return show(Pill:new{ kind = "info", glyph = o.icon == nil and "check" or o.icon or nil, variants = { { { t = str, b = true } } }, timeout = o.timeout or 2.5, bottom = o.bottom })
end
-- A failure. retry: function to run when the pill is tapped, or nil.
function Toast.error(str, retry, o)
o = o or {}
local variants
if retry then
variants = {
{ { t = str, b = true }, { t = " " .. _("Retry"), b = true, col = INK.white } },
{ { t = str, b = true } },
}
else
variants = { { { t = str, b = true } } }
end
return show(Pill:new{ kind = retry and "error" or "info", glyph = "triangle-alert", variants = variants, on_tap = retry, timeout = o.timeout or (retry and 10 or 4), bottom = o.bottom })
end
-- What is loading. Returns the pill: hand it to Trapper as its trap widget,
-- then close it when the request returns.
function Toast.loading(str, o)
o = o or {}
local hint = INK.gc
local pill = Pill:new{
kind = "loading",
glyph = "hourglass",
variants = {
{ { t = str, b = true }, { t = " " .. _("Tap anywhere to stop"), col = hint } },
{ { t = str, b = true }, { t = " " .. _("Tap to stop"), col = hint } },
{ { t = str, b = true } },
},
bottom = o.bottom,
}
if o.dismissable == false then
pill.handleInput = function() return true end
end
show(pill)
UIManager:forceRePaint()
return pill
end
-- Closes whatever pill is up (a page that opens over it).
function Toast.dismiss()
if current and not current.closed then current:close() end
end
return Toast