mirror of
https://github.com/koreader/koreader
synced 2024-11-18 03:25:46 +00:00
3af268dd7a
- FocusManager: allow managing sub widgets by merging their "layout" in the main one; make "press" support simpler by handling it as a fake tap event at the center of the focused widget. - Setup gestures on non-touch devices for new focus manager. - ToggleSwitch: use child layout. - ButtonProgressWidget: use child layout. - SpinWidget and DoubleSpinWidget: add keyboard navigation.
473 lines
18 KiB
Lua
473 lines
18 KiB
Lua
--[[--
|
|
A button widget that shows text or an icon and handles callback when tapped.
|
|
|
|
@usage
|
|
local Button = require("ui/widget/button")
|
|
local button = Button:new{
|
|
text = _("Press me!"),
|
|
enabled = false, -- defaults to true
|
|
callback = some_callback_function,
|
|
width = Screen:scaleBySize(50),
|
|
max_width = Screen:scaleBySize(100),
|
|
bordersize = Screen:scaleBySize(3),
|
|
margin = 0,
|
|
padding = Screen:scaleBySize(2),
|
|
}
|
|
--]]
|
|
|
|
local Blitbuffer = require("ffi/blitbuffer")
|
|
local CenterContainer = require("ui/widget/container/centercontainer")
|
|
local Device = require("device")
|
|
local Font = require("ui/font")
|
|
local FrameContainer = require("ui/widget/container/framecontainer")
|
|
local Geom = require("ui/geometry")
|
|
local GestureRange = require("ui/gesturerange")
|
|
local IconWidget = require("ui/widget/iconwidget")
|
|
local InputContainer = require("ui/widget/container/inputcontainer")
|
|
local Size = require("ui/size")
|
|
local TextBoxWidget = require("ui/widget/textboxwidget")
|
|
local TextWidget = require("ui/widget/textwidget")
|
|
local UIManager = require("ui/uimanager")
|
|
local _ = require("gettext")
|
|
local Screen = Device.screen
|
|
local logger = require("logger")
|
|
|
|
local Button = InputContainer:new{
|
|
text = nil, -- mandatory (unless icon is provided)
|
|
text_func = nil,
|
|
icon = nil,
|
|
icon_width = Screen:scaleBySize(DGENERIC_ICON_SIZE), -- our icons are square
|
|
icon_height = Screen:scaleBySize(DGENERIC_ICON_SIZE),
|
|
icon_rotation_angle = 0,
|
|
preselect = false,
|
|
callback = nil,
|
|
enabled = true,
|
|
hidden = false,
|
|
allow_hold_when_disabled = false,
|
|
margin = 0,
|
|
bordersize = Size.border.button,
|
|
background = Blitbuffer.COLOR_WHITE,
|
|
radius = nil,
|
|
padding = Size.padding.button,
|
|
padding_h = nil,
|
|
padding_v = nil,
|
|
width = nil,
|
|
max_width = nil,
|
|
avoid_text_truncation = true,
|
|
text_font_face = "cfont",
|
|
text_font_size = 20,
|
|
text_font_bold = true,
|
|
vsync = nil, -- when "flash_ui" is enabled, allow bundling the highlight with the callback, and fence that batch away from the unhighlight. Avoid delays when callback requires a "partial" on Kobo Mk. 7, c.f., ffi/framebuffer_mxcfb for more details.
|
|
}
|
|
|
|
function Button:init()
|
|
-- Prefer an optional text_func over text
|
|
if self.text_func and type(self.text_func) == "function" then
|
|
self.text = self.text_func()
|
|
end
|
|
|
|
-- Point tap_input to hold_input if requested
|
|
if self.call_hold_input_on_tap then
|
|
self.tap_input = self.hold_input
|
|
end
|
|
|
|
if not self.padding_h then
|
|
self.padding_h = self.padding
|
|
end
|
|
if not self.padding_v then
|
|
self.padding_v = self.padding
|
|
end
|
|
|
|
if self.text then
|
|
local max_width = self.max_width and self.max_width - 2*self.padding_h - 2*self.margin - 2*self.bordersize or nil
|
|
self.label_widget = TextWidget:new{
|
|
text = self.text,
|
|
max_width = max_width,
|
|
fgcolor = self.enabled and Blitbuffer.COLOR_BLACK or Blitbuffer.COLOR_DARK_GRAY,
|
|
bold = self.text_font_bold,
|
|
face = Font:getFace(self.text_font_face, self.text_font_size)
|
|
}
|
|
self.did_truncation_tweaks = false
|
|
if self.avoid_text_truncation and self.label_widget:isTruncated() then
|
|
self.did_truncation_tweaks = true
|
|
local max_height = self.label_widget:getSize().h
|
|
local font_size_2_lines = TextBoxWidget:getFontSizeToFitHeight(max_height, 2, 0)
|
|
while self.label_widget:isTruncated() do
|
|
local new_size = self.label_widget.face.orig_size - 1
|
|
if new_size < font_size_2_lines then
|
|
-- Switch to a 2-lines TextBoxWidget
|
|
self.label_widget:free()
|
|
self.label_widget = TextBoxWidget:new{
|
|
text = self.text,
|
|
line_height = 0,
|
|
alignment = "center",
|
|
width = max_width,
|
|
height = max_height,
|
|
height_adjust = true,
|
|
height_overflow_show_ellipsis = true,
|
|
fgcolor = self.enabled and Blitbuffer.COLOR_BLACK or Blitbuffer.COLOR_DARK_GRAY,
|
|
bold = self.text_font_bold,
|
|
face = Font:getFace(self.text_font_face, font_size_2_lines)
|
|
}
|
|
if not self.label_widget.has_split_inside_word then
|
|
break
|
|
end
|
|
-- No good wrap opportunity (split inside a word): ignore this TextBoxWidget
|
|
-- and go on with a TextWidget with the smaller font size
|
|
end
|
|
if new_size < 8 then -- don't go too small
|
|
break
|
|
end
|
|
self.label_widget:free()
|
|
self.label_widget = TextWidget:new{
|
|
text = self.text,
|
|
max_width = max_width,
|
|
fgcolor = self.enabled and Blitbuffer.COLOR_BLACK or Blitbuffer.COLOR_DARK_GRAY,
|
|
bold = self.text_font_bold,
|
|
face = Font:getFace(self.text_font_face, new_size)
|
|
}
|
|
end
|
|
end
|
|
else
|
|
self.label_widget = IconWidget:new{
|
|
icon = self.icon,
|
|
rotation_angle = self.icon_rotation_angle,
|
|
dim = not self.enabled,
|
|
width = self.icon_width,
|
|
height = self.icon_height,
|
|
}
|
|
end
|
|
local widget_size = self.label_widget:getSize()
|
|
if self.width == nil then
|
|
self.width = widget_size.w
|
|
end
|
|
-- set FrameContainer content
|
|
self.frame = FrameContainer:new{
|
|
margin = self.margin,
|
|
show_parent = self.show_parent,
|
|
bordersize = self.bordersize,
|
|
background = self.background,
|
|
radius = self.radius,
|
|
padding_top = self.padding_v,
|
|
padding_bottom = self.padding_v,
|
|
padding_left = self.padding_h,
|
|
padding_right = self.padding_h,
|
|
CenterContainer:new{
|
|
dimen = Geom:new{
|
|
w = self.width,
|
|
h = widget_size.h
|
|
},
|
|
self.label_widget,
|
|
}
|
|
}
|
|
if self.preselect then
|
|
self.frame.invert = true
|
|
end
|
|
self.dimen = self.frame:getSize()
|
|
self[1] = self.frame
|
|
self.ges_events = {
|
|
TapSelectButton = {
|
|
GestureRange:new{
|
|
ges = "tap",
|
|
range = self.dimen,
|
|
},
|
|
doc = "Tap Button",
|
|
},
|
|
HoldSelectButton = {
|
|
GestureRange:new{
|
|
ges = "hold",
|
|
range = self.dimen,
|
|
},
|
|
doc = "Hold Button",
|
|
},
|
|
-- Safe-guard for when used inside a MovableContainer
|
|
HoldReleaseSelectButton = {
|
|
GestureRange:new{
|
|
ges = "hold_release",
|
|
range = self.dimen,
|
|
},
|
|
}
|
|
}
|
|
end
|
|
|
|
function Button:setText(text, width)
|
|
if text ~= self.text then
|
|
-- Don't trash the frame if we're already a text button, and we're keeping the geometry intact
|
|
if self.text and width and width == self.width and not self.did_truncation_tweaks then
|
|
self.text = text
|
|
self.label_widget:setText(text)
|
|
else
|
|
self.text = text
|
|
self.width = width
|
|
self.label_widget:free()
|
|
self:init()
|
|
end
|
|
end
|
|
end
|
|
|
|
function Button:setIcon(icon, width)
|
|
if icon ~= self.icon then
|
|
self.icon = icon
|
|
self.width = width
|
|
self.label_widget:free()
|
|
self:init()
|
|
end
|
|
end
|
|
|
|
function Button:onFocus()
|
|
if self.no_focus then return end
|
|
self.frame.invert = true
|
|
return true
|
|
end
|
|
|
|
function Button:onUnfocus()
|
|
if self.no_focus then return end
|
|
self.frame.invert = false
|
|
return true
|
|
end
|
|
|
|
function Button:enable()
|
|
if not self.enabled then
|
|
if self.text then
|
|
self.label_widget.fgcolor = Blitbuffer.COLOR_BLACK
|
|
else
|
|
self.label_widget.dim = false
|
|
end
|
|
self.enabled = true
|
|
end
|
|
end
|
|
|
|
function Button:disable()
|
|
if self.enabled then
|
|
if self.text then
|
|
self.label_widget.fgcolor = Blitbuffer.COLOR_DARK_GRAY
|
|
else
|
|
self.label_widget.dim = true
|
|
end
|
|
self.enabled = false
|
|
end
|
|
end
|
|
|
|
-- This is used by pagination buttons with a hold_input registered that we want to *sometimes* inhibit,
|
|
-- meaning we want the Button disabled, but *without* dimming the text...
|
|
function Button:disableWithoutDimming()
|
|
self.enabled = false
|
|
if self.text then
|
|
self.label_widget.fgcolor = Blitbuffer.COLOR_BLACK
|
|
else
|
|
self.label_widget.dim = false
|
|
end
|
|
end
|
|
|
|
function Button:enableDisable(enable)
|
|
if enable then
|
|
self:enable()
|
|
else
|
|
self:disable()
|
|
end
|
|
end
|
|
|
|
function Button:hide()
|
|
if self.icon and not self.hidden then
|
|
self.frame.orig_background = self.frame.background
|
|
self.frame.background = nil
|
|
self.label_widget.hide = true
|
|
self.hidden = true
|
|
end
|
|
end
|
|
|
|
function Button:show()
|
|
if self.icon and self.hidden then
|
|
self.label_widget.hide = false
|
|
self.frame.background = self.frame.orig_background
|
|
self.hidden = false
|
|
end
|
|
end
|
|
|
|
function Button:showHide(show)
|
|
if show then
|
|
self:show()
|
|
else
|
|
self:hide()
|
|
end
|
|
end
|
|
|
|
-- Used by onTapSelectButton to handle visual feedback when flash_ui is enabled
|
|
function Button:_doFeedbackHighlight()
|
|
-- NOTE: self[1] -> self.frame, if you're confused about what this does vs. onFocus/onUnfocus ;).
|
|
if self.text then
|
|
-- We only want the button's *highlight* to have rounded corners (otherwise they're redundant, same color as the bg).
|
|
-- The nil check is to discriminate the default from callers that explicitly request a specific radius.
|
|
if self[1].radius == nil then
|
|
self[1].radius = Size.radius.button
|
|
-- And here, it's easier to just invert the bg/fg colors ourselves,
|
|
-- so as to preserve the rounded corners in one step.
|
|
self[1].background = self[1].background:invert()
|
|
self.label_widget.fgcolor = self.label_widget.fgcolor:invert()
|
|
-- We do *NOT* set the invert flag, because it just adds an invertRect step at the end of the paintTo process,
|
|
-- and we've already taken care of inversion in a way that won't mangle the rounded corners.
|
|
else
|
|
self[1].invert = true
|
|
end
|
|
|
|
-- This repaints *now*, unlike setDirty
|
|
UIManager:widgetRepaint(self[1], self[1].dimen.x, self[1].dimen.y)
|
|
else
|
|
self[1].invert = true
|
|
UIManager:widgetInvert(self[1], self[1].dimen.x, self[1].dimen.y)
|
|
end
|
|
UIManager:setDirty(nil, "fast", self[1].dimen)
|
|
end
|
|
|
|
function Button:_undoFeedbackHighlight(is_translucent)
|
|
if self.text then
|
|
if self[1].radius == Size.radius.button then
|
|
self[1].radius = nil
|
|
self[1].background = self[1].background:invert()
|
|
self.label_widget.fgcolor = self.label_widget.fgcolor:invert()
|
|
else
|
|
self[1].invert = false
|
|
end
|
|
UIManager:widgetRepaint(self[1], self[1].dimen.x, self[1].dimen.y)
|
|
else
|
|
self[1].invert = false
|
|
UIManager:widgetInvert(self[1], self[1].dimen.x, self[1].dimen.y)
|
|
end
|
|
|
|
if is_translucent then
|
|
-- If our parent belongs to a translucent MovableContainer, we need to repaint it on unhighlight in order to honor alpha,
|
|
-- because our highlight/unhighlight will have made the Button fully opaque.
|
|
-- UIManager will detect transparency and then takes care of also repainting what's underneath us to avoid alpha layering glitches.
|
|
UIManager:setDirty(self.show_parent, "ui", self[1].dimen)
|
|
else
|
|
-- In case the callback itself won't enqueue a refresh region that includes us, do it ourselves.
|
|
-- If the button is disabled, switch to UI to make sure the gray comes through unharmed ;).
|
|
UIManager:setDirty(nil, self.enabled and "fast" or "ui", self[1].dimen)
|
|
end
|
|
end
|
|
|
|
function Button:onTapSelectButton()
|
|
if self.enabled or self.allow_tap_when_disabled then
|
|
if self.callback then
|
|
if G_reader_settings:isFalse("flash_ui") then
|
|
self.callback()
|
|
else
|
|
-- NOTE: We have a few tricks up our sleeve in case our parent is inside a translucent MovableContainer...
|
|
local is_translucent = self.show_parent and self.show_parent.movable and self.show_parent.movable.alpha
|
|
|
|
-- Highlight
|
|
--
|
|
self:_doFeedbackHighlight()
|
|
|
|
-- Force the refresh by draining the refresh queue *now*, so we have a chance to see the highlight on its own, before whatever the callback will do.
|
|
if not self.vsync then
|
|
-- NOTE: Except when a Button is flagged vsync, in which case we *want* to bundle the highlight with the callback, to prevent further delays
|
|
UIManager:forceRePaint()
|
|
|
|
-- NOTE: Yield to the kernel for a tiny slice of time, otherwise, writing to the same fb region as the refresh we've just requested may be race-y,
|
|
-- causing mild variants of our friend the papercut refresh glitch ;).
|
|
-- Remember that the whole eInk refresh dance is completely asynchronous: we *request* a refresh from the kernel,
|
|
-- but it's up to the EPDC to schedule that however it sees fit...
|
|
-- The other approach would be to *ask* the EPDC to block until it's *completely* done,
|
|
-- but that's too much (because we only care about it being done *reading* the fb),
|
|
-- and that could take upwards of 300ms, which is also way too much ;).
|
|
UIManager:yieldToEPDC()
|
|
end
|
|
|
|
-- Unhighlight
|
|
--
|
|
-- We'll *paint* the unhighlight now, because at this point we can still be sure that our widget exists,
|
|
-- and that anything we do will not impact whatever the callback does (i.e., that we draw *below* whatever the callback might show).
|
|
-- We won't *fence* the refresh (i.e., it's queued, but we don't actually drain the queue yet), though, to ensure that we do not delay the callback, and that the unhighlight essentially blends into whatever the callback does.
|
|
-- Worst case scenario, we'll simply have "wasted" a tiny subwidget repaint if the callback closed us,
|
|
-- but doing it this way allows us to avoid a large array of potential interactions with whatever the callback may paint/refresh if we were to handle the unhighlight post-callback,
|
|
-- which would require a number of possibly brittle heuristics to handle.
|
|
-- NOTE: If a Button is marked vsync, we want to keep it highlighted for now (in order for said highlight to be visible during the callback refresh), we'll remove the highlight post-callback.
|
|
if not self.vsync then
|
|
self:_undoFeedbackHighlight(is_translucent)
|
|
end
|
|
|
|
-- Callback
|
|
--
|
|
self.callback()
|
|
|
|
-- Check if the callback reset transparency...
|
|
is_translucent = is_translucent and self.show_parent.movable.alpha
|
|
|
|
UIManager:forceRePaint() -- Ensures whatever the callback wanted to paint will be shown *now*...
|
|
if self.vsync then
|
|
-- NOTE: This is mainly useful when the callback caused a REAGL update that we do not explicitly fence via MXCFB_WAIT_FOR_UPDATE_COMPLETE already, (i.e., Kobo Mk. 7).
|
|
UIManager:waitForVSync() -- ...and that the EPDC will not wait to coalesce it with the *next* update,
|
|
-- because that would have a chance to noticeably delay it until the unhighlight.
|
|
end
|
|
|
|
-- Unhighlight
|
|
--
|
|
-- NOTE: If a Button is marked vsync, we have a guarantee from the programmer that the widget it belongs to is still alive and top-level post-callback,
|
|
-- so we can do this safely without risking UI glitches.
|
|
if self.vsync then
|
|
self:_undoFeedbackHighlight(is_translucent)
|
|
UIManager:forceRePaint()
|
|
end
|
|
end
|
|
elseif self.tap_input then
|
|
self:onInput(self.tap_input)
|
|
elseif type(self.tap_input_func) == "function" then
|
|
self:onInput(self.tap_input_func())
|
|
end
|
|
end
|
|
|
|
if self.readonly ~= true then
|
|
return true
|
|
end
|
|
end
|
|
|
|
-- Allow repainting and refreshing *a* specific Button, instead of the full screen/parent stack
|
|
function Button:refresh()
|
|
-- We can only be called on a Button that's already been painted once, which allows us to know where we're positioned,
|
|
-- thanks to the frame's geometry.
|
|
-- e.g., right after a setText or setIcon is a no-go, as those kill the frame.
|
|
-- (Although, setText, if called with the current width, will conserve the frame).
|
|
if not self[1].dimen then
|
|
logger.dbg("Button:", self, "attempted a repaint in an unpainted frame!")
|
|
return
|
|
end
|
|
UIManager:widgetRepaint(self[1], self[1].dimen.x, self.dimen.y)
|
|
|
|
UIManager:setDirty(nil, function()
|
|
return self.enabled and "fast" or "ui", self[1].dimen
|
|
end)
|
|
end
|
|
|
|
function Button:onHoldSelectButton()
|
|
-- If we're going to process this hold, we must make
|
|
-- sure to also handle its hold_release below, so it's
|
|
-- not propagated up to a MovableContainer
|
|
self._hold_handled = nil
|
|
if self.enabled or self.allow_hold_when_disabled then
|
|
if self.hold_callback then
|
|
self.hold_callback()
|
|
self._hold_handled = true
|
|
elseif self.hold_input then
|
|
self:onInput(self.hold_input, true)
|
|
self._hold_handled = true
|
|
elseif type(self.hold_input_func) == "function" then
|
|
self:onInput(self.hold_input_func(), true)
|
|
self._hold_handled = true
|
|
end
|
|
end
|
|
if self.readonly ~= true then
|
|
return true
|
|
end
|
|
end
|
|
|
|
function Button:onHoldReleaseSelectButton()
|
|
if self._hold_handled then
|
|
self._hold_handled = nil
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
return Button
|