mirror of
https://github.com/koreader/koreader.git
synced 2025-08-10 00:52:38 +00:00
Lots of code was doing some renderText calls to get the size of some text string, and truncate it to some width if needed, with or without an added ellipsis, before instantiating a TextWidget with that tweaked text string. This PR fixes/adds some properties and methods to TextWidget so all that can be done by it. It makes the calling code simpler, as they don't need to use RenderText directly. (Additionally, when we go at using Harfbuzz for text rendering, we'll just have to update or replace textwidget.lua without the need to update any higher level code.) Also: - RenderText: removed the space added by truncateTextByWidth after the ellipsis, as it doesn't feel needed, and break right alignment of the ellipsis with other texts. - KeyValuePage: fix some subtle size and alignment issues. - NumberPickerWidget: fix font size (provided font size was not used)
596 lines
20 KiB
Lua
596 lines
20 KiB
Lua
--[[--
|
|
Widget that presents a multi-page to show key value pairs.
|
|
|
|
Example:
|
|
|
|
local Foo = KeyValuePage:new{
|
|
title = "Statistics",
|
|
kv_pairs = {
|
|
{"Current period", "00:00:00"},
|
|
-- single or more "-" will generate a solid line
|
|
"----------------------------",
|
|
{"Page to read", "5"},
|
|
{"Time to read", "00:01:00"},
|
|
{"Press me", "will invoke the callback",
|
|
callback = function() print("hello") end },
|
|
},
|
|
}
|
|
UIManager:show(Foo)
|
|
|
|
]]
|
|
|
|
local Blitbuffer = require("ffi/blitbuffer")
|
|
local BottomContainer = require("ui/widget/container/bottomcontainer")
|
|
local Button = require("ui/widget/button")
|
|
local CloseButton = require("ui/widget/closebutton")
|
|
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 HorizontalGroup = require("ui/widget/horizontalgroup")
|
|
local HorizontalSpan = require("ui/widget/horizontalspan")
|
|
local InputContainer = require("ui/widget/container/inputcontainer")
|
|
local LeftContainer = require("ui/widget/container/leftcontainer")
|
|
local LineWidget = require("ui/widget/linewidget")
|
|
local OverlapGroup = require("ui/widget/overlapgroup")
|
|
local Size = require("ui/size")
|
|
local TextViewer = require("ui/widget/textviewer")
|
|
local TextWidget = require("ui/widget/textwidget")
|
|
local UIManager = require("ui/uimanager")
|
|
local VerticalGroup = require("ui/widget/verticalgroup")
|
|
local VerticalSpan = require("ui/widget/verticalspan")
|
|
local Input = Device.input
|
|
local Screen = Device.screen
|
|
local T = require("ffi/util").template
|
|
local _ = require("gettext")
|
|
|
|
local KeyValueTitle = VerticalGroup:new{
|
|
kv_page = nil,
|
|
title = "",
|
|
tface = Font:getFace("tfont"),
|
|
align = "left",
|
|
use_top_page_count = false,
|
|
}
|
|
|
|
function KeyValueTitle:init()
|
|
self.close_button = CloseButton:new{ window = self }
|
|
local btn_width = self.close_button:getSize().w
|
|
-- title and close button
|
|
table.insert(self, OverlapGroup:new{
|
|
dimen = { w = self.width },
|
|
TextWidget:new{
|
|
text = self.title,
|
|
max_width = self.width - btn_width,
|
|
face = self.tface,
|
|
},
|
|
self.close_button,
|
|
})
|
|
-- page count and separation line
|
|
self.title_bottom = OverlapGroup:new{
|
|
dimen = { w = self.width, h = Size.line.thick },
|
|
LineWidget:new{
|
|
dimen = Geom:new{ w = self.width, h = Size.line.thick },
|
|
background = Blitbuffer.COLOR_DARK_GRAY,
|
|
style = "solid",
|
|
},
|
|
}
|
|
if self.use_top_page_count then
|
|
self.page_cnt = FrameContainer:new{
|
|
padding = Size.padding.default,
|
|
margin = 0,
|
|
bordersize = 0,
|
|
background = Blitbuffer.COLOR_WHITE,
|
|
-- overlap offset x will be updated in setPageCount method
|
|
overlap_offset = {0, -15},
|
|
TextWidget:new{
|
|
text = "", -- page count
|
|
fgcolor = Blitbuffer.COLOR_DARK_GRAY,
|
|
face = Font:getFace("smallffont"),
|
|
},
|
|
}
|
|
table.insert(self.title_bottom, self.page_cnt)
|
|
end
|
|
table.insert(self, self.title_bottom)
|
|
table.insert(self, VerticalSpan:new{ width = Size.span.vertical_large })
|
|
end
|
|
|
|
function KeyValueTitle:setPageCount(curr, total)
|
|
if total == 1 then
|
|
-- remove page count if there is only one page
|
|
table.remove(self.title_bottom, 2)
|
|
return
|
|
end
|
|
self.page_cnt[1]:setText(curr .. "/" .. total)
|
|
self.page_cnt.overlap_offset[1] = (self.width - self.page_cnt:getSize().w - 10)
|
|
self.title_bottom[2] = self.page_cnt
|
|
end
|
|
|
|
function KeyValueTitle:onClose()
|
|
self.kv_page:onClose()
|
|
return true
|
|
end
|
|
|
|
|
|
local KeyValueItem = InputContainer:new{
|
|
key = nil,
|
|
value = nil,
|
|
cface = Font:getFace("smallinfofont"),
|
|
tface = Font:getFace("smallinfofontbold"),
|
|
width = nil,
|
|
height = nil,
|
|
textviewer_width = nil,
|
|
textviewer_height = nil,
|
|
value_overflow_align = "left",
|
|
}
|
|
|
|
function KeyValueItem:init()
|
|
self.dimen = Geom:new{w = self.width, h = self.height}
|
|
|
|
if self.callback and Device:isTouchDevice() then
|
|
self.ges_events.Tap = {
|
|
GestureRange:new{
|
|
ges = "tap",
|
|
range = self.dimen,
|
|
}
|
|
}
|
|
end
|
|
|
|
-- self.value may contain some control characters (\n \t...) that would
|
|
-- be rendered as a square. Replace them with a shorter and nicer '|'.
|
|
-- (Let self.value untouched, as with Hold, the original value can be
|
|
-- displayed correctly in TextViewer.)
|
|
local tvalue = tostring(self.value)
|
|
tvalue = tvalue:gsub("[\n\t]", "|")
|
|
|
|
local frame_padding = Size.padding.default
|
|
local frame_internal_width = self.width - frame_padding * 2
|
|
-- Default widths (and position of value widget) if each text fits in 1/2 screen width
|
|
local key_w = frame_internal_width / 2
|
|
local value_w = frame_internal_width / 2
|
|
|
|
local key_widget = TextWidget:new{
|
|
text = self.key,
|
|
max_width = frame_internal_width,
|
|
face = self.tface,
|
|
}
|
|
local value_widget = TextWidget:new{
|
|
text = tvalue,
|
|
max_width = frame_internal_width,
|
|
face = self.cface,
|
|
}
|
|
local key_w_rendered = key_widget:getWidth()
|
|
local value_w_rendered = value_widget:getWidth()
|
|
|
|
-- As both key_widget and value_width will be in a HorizontalGroup,
|
|
-- and key is always left aligned, we can just tweak the key width
|
|
-- to position the value_widget
|
|
local value_prepend_space = false
|
|
local value_align_right = false
|
|
local fit_right_align = true -- by default, really right align
|
|
|
|
if key_w_rendered > key_w or value_w_rendered > value_w then
|
|
-- One (or both) does not fit in 1/2 width
|
|
if key_w_rendered + value_w_rendered > frame_internal_width then
|
|
-- Both do not fit: one has to be truncated so they fit
|
|
if key_w_rendered >= value_w_rendered then
|
|
-- Rare case: key larger than value.
|
|
-- We should have kept our keys small, smaller than 1/2 width.
|
|
-- If it is larger than value, it's that value is kinda small,
|
|
-- so keep the whole value, and truncate the key
|
|
key_w = frame_internal_width - value_w_rendered
|
|
else
|
|
-- Usual case: value larger than key.
|
|
-- Keep our small key, fit the value in the remaining width,
|
|
-- prepend some space to separate them
|
|
key_w = key_w_rendered
|
|
value_prepend_space = true
|
|
end
|
|
value_align_right = true -- so the ellipsis touches the screen right border
|
|
if self.value_overflow_align ~= "right" and self.value_align ~= "right" then
|
|
-- Don't adjust the ellipsis to the screen right border,
|
|
-- so the left of text is aligned with other truncated texts
|
|
fit_right_align = false
|
|
end
|
|
-- Allow for displaying the non-truncated texts with Hold
|
|
if Device:isTouchDevice() then
|
|
self.ges_events.Hold = {
|
|
GestureRange:new{
|
|
ges = "hold",
|
|
range = self.dimen,
|
|
}
|
|
}
|
|
end
|
|
else
|
|
-- Both can fit: break the 1/2 widths
|
|
if self.value_overflow_align == "right" or self.value_align == "right" then
|
|
key_w = frame_internal_width - value_w_rendered
|
|
value_align_right = true
|
|
else
|
|
key_w = key_w_rendered
|
|
value_prepend_space = true
|
|
end
|
|
end
|
|
-- In all the above case, we set the right key_w to include any
|
|
-- needed in-between padding: value_w is what's left.
|
|
value_w = frame_internal_width - key_w
|
|
else
|
|
if self.value_align == "right" then
|
|
key_w = frame_internal_width - value_w_rendered
|
|
value_w = value_w_rendered
|
|
value_align_right = true
|
|
end
|
|
end
|
|
|
|
-- Adjust widgets' max widths and text as needed
|
|
if value_prepend_space then
|
|
value_widget:setText(" "..tvalue)
|
|
end
|
|
value_widget:setMaxWidth(value_w)
|
|
if fit_right_align and value_align_right and value_widget:getWidth() < value_w then
|
|
-- Because of truncation at glyph boundaries, value_widget
|
|
-- may be a tad smaller than the specified value_w:
|
|
-- add some padding to key_w so value is pushed to the screen right border
|
|
key_w = key_w + ( value_w - value_widget:getWidth() )
|
|
end
|
|
key_widget:setMaxWidth(key_w)
|
|
|
|
-- For debugging positioning:
|
|
-- value_widget = FrameContainer:new{ padding=0, margin=0, bordersize=1, value_widget }
|
|
|
|
self[1] = FrameContainer:new{
|
|
padding = frame_padding,
|
|
bordersize = 0,
|
|
HorizontalGroup:new{
|
|
dimen = self.dimen:copy(),
|
|
LeftContainer:new{
|
|
dimen = {
|
|
w = key_w,
|
|
h = self.height
|
|
},
|
|
key_widget,
|
|
},
|
|
LeftContainer:new{
|
|
dimen = {
|
|
w = value_w,
|
|
h = self.height
|
|
},
|
|
value_widget,
|
|
}
|
|
}
|
|
}
|
|
end
|
|
|
|
function KeyValueItem:onTap()
|
|
if self.callback then
|
|
if G_reader_settings:isFalse("flash_ui") then
|
|
self.callback()
|
|
else
|
|
self[1].invert = true
|
|
UIManager:widgetRepaint(self[1], self[1].dimen.x, self[1].dimen.y)
|
|
UIManager:setDirty(nil, function()
|
|
return "fast", self[1].dimen
|
|
end)
|
|
UIManager:tickAfterNext(function()
|
|
self.callback()
|
|
self[1].invert = false
|
|
UIManager:widgetRepaint(self[1], self[1].dimen.x, self[1].dimen.y)
|
|
UIManager:setDirty(nil, function()
|
|
return "ui", self[1].dimen
|
|
end)
|
|
end)
|
|
end
|
|
end
|
|
return true
|
|
end
|
|
|
|
function KeyValueItem:onHold()
|
|
local textviewer = TextViewer:new{
|
|
title = self.key,
|
|
text = self.value,
|
|
width = self.textviewer_width,
|
|
height = self.textviewer_height,
|
|
}
|
|
UIManager:show(textviewer)
|
|
return true
|
|
end
|
|
|
|
|
|
local KeyValuePage = InputContainer:new{
|
|
title = "",
|
|
width = nil,
|
|
height = nil,
|
|
-- index for the first item to show
|
|
show_page = 1,
|
|
use_top_page_count = false,
|
|
-- aligment of value when key or value overflows its reserved width (for
|
|
-- now: 50%): "left" (stick to key), "right" (stick to scren right border)
|
|
value_overflow_align = "left",
|
|
}
|
|
|
|
function KeyValuePage:init()
|
|
self.dimen = Geom:new{
|
|
w = self.width or Screen:getWidth(),
|
|
h = self.height or Screen:getHeight(),
|
|
}
|
|
if self.dimen.w == Screen:getWidth() and self.dimen.h == Screen:getHeight() then
|
|
self.covers_fullscreen = true -- hint for UIManager:_repaint()
|
|
end
|
|
|
|
if Device:hasKeys() then
|
|
self.key_events = {
|
|
Close = { {"Back"}, doc = "close page" },
|
|
NextPage = {{Input.group.PgFwd}, doc = "next page"},
|
|
PrevPage = {{Input.group.PgBack}, doc = "prev page"},
|
|
}
|
|
end
|
|
if Device:isTouchDevice() then
|
|
self.ges_events.Swipe = {
|
|
GestureRange:new{
|
|
ges = "swipe",
|
|
range = self.dimen,
|
|
}
|
|
}
|
|
end
|
|
|
|
-- return button
|
|
self.page_return_arrow = Button:new{
|
|
icon = "resources/icons/appbar.arrow.left.up.png",
|
|
callback = function() self:onReturn() end,
|
|
bordersize = 0,
|
|
show_parent = self,
|
|
}
|
|
-- group for page info
|
|
self.page_info_left_chev = Button:new{
|
|
icon = "resources/icons/appbar.chevron.left.png",
|
|
callback = function() self:prevPage() end,
|
|
bordersize = 0,
|
|
show_parent = self,
|
|
}
|
|
self.page_info_right_chev = Button:new{
|
|
icon = "resources/icons/appbar.chevron.right.png",
|
|
callback = function() self:nextPage() end,
|
|
bordersize = 0,
|
|
show_parent = self,
|
|
}
|
|
self.page_info_first_chev = Button:new{
|
|
icon = "resources/icons/appbar.chevron.first.png",
|
|
callback = function() self:goToPage(1) end,
|
|
bordersize = 0,
|
|
show_parent = self,
|
|
}
|
|
self.page_info_last_chev = Button:new{
|
|
icon = "resources/icons/appbar.chevron.last.png",
|
|
callback = function() self:goToPage(self.pages) end,
|
|
bordersize = 0,
|
|
show_parent = self,
|
|
}
|
|
self.page_info_spacer = HorizontalSpan:new{
|
|
width = Screen:scaleBySize(32),
|
|
}
|
|
self.page_return_spacer = HorizontalSpan:new{
|
|
width = self.page_return_arrow:getSize().w
|
|
}
|
|
|
|
if self.callback_return == nil and self.return_button == nil then
|
|
self.page_return_arrow:hide()
|
|
elseif self.callback_return == nil then
|
|
self.page_return_arrow:disable()
|
|
end
|
|
|
|
self.page_info_left_chev:hide()
|
|
self.page_info_right_chev:hide()
|
|
self.page_info_first_chev:hide()
|
|
self.page_info_last_chev:hide()
|
|
|
|
self.page_info_text = Button:new{
|
|
text = "",
|
|
hold_input = {
|
|
title = _("Enter page number"),
|
|
type = "number",
|
|
hint_func = function()
|
|
return "(" .. "1 - " .. self.pages .. ")"
|
|
end,
|
|
callback = function(input)
|
|
local page = tonumber(input)
|
|
if page and page >= 1 and page <= self.pages then
|
|
self:goToPage(page)
|
|
end
|
|
end,
|
|
},
|
|
bordersize = 0,
|
|
margin = Screen:scaleBySize(20),
|
|
text_font_face = "pgfont",
|
|
text_font_bold = false,
|
|
}
|
|
self.page_info = HorizontalGroup:new{
|
|
self.page_return_arrow,
|
|
self.page_info_first_chev,
|
|
self.page_info_spacer,
|
|
self.page_info_left_chev,
|
|
self.page_info_text,
|
|
self.page_info_right_chev,
|
|
self.page_info_spacer,
|
|
self.page_info_last_chev,
|
|
self.page_return_spacer,
|
|
}
|
|
|
|
local footer = BottomContainer:new{
|
|
dimen = self.dimen:copy(),
|
|
self.page_info,
|
|
}
|
|
|
|
local padding = Size.padding.large
|
|
self.item_width = self.dimen.w - 2 * padding
|
|
self.item_height = Size.item.height_default
|
|
-- setup title bar
|
|
self.title_bar = KeyValueTitle:new{
|
|
title = self.title,
|
|
width = self.item_width,
|
|
height = self.item_height,
|
|
use_top_page_count = self.use_top_page_count,
|
|
kv_page = self,
|
|
}
|
|
-- setup main content
|
|
self.item_margin = self.item_height / 4
|
|
local line_height = self.item_height + 2 * self.item_margin
|
|
local content_height = self.dimen.h - self.title_bar:getSize().h - self.page_info:getSize().h
|
|
self.items_per_page = math.floor(content_height / line_height)
|
|
self.pages = math.ceil(#self.kv_pairs / self.items_per_page)
|
|
self.main_content = VerticalGroup:new{}
|
|
|
|
-- set textviewer height to let our title fully visible
|
|
self.textviewer_width = self.item_width
|
|
self.textviewer_height = self.dimen.h - 2*self.title_bar:getSize().h
|
|
|
|
self:_populateItems()
|
|
|
|
local content = OverlapGroup:new{
|
|
dimen = self.dimen:copy(),
|
|
VerticalGroup:new{
|
|
align = "left",
|
|
self.title_bar,
|
|
self.main_content,
|
|
},
|
|
footer,
|
|
}
|
|
-- assemble page
|
|
self[1] = FrameContainer:new{
|
|
height = self.dimen.h,
|
|
padding = padding,
|
|
bordersize = 0,
|
|
background = Blitbuffer.COLOR_WHITE,
|
|
content
|
|
}
|
|
end
|
|
|
|
function KeyValuePage:nextPage()
|
|
local new_page = math.min(self.show_page+1, self.pages)
|
|
if new_page > self.show_page then
|
|
self.show_page = new_page
|
|
self:_populateItems()
|
|
end
|
|
end
|
|
|
|
function KeyValuePage:prevPage()
|
|
local new_page = math.max(self.show_page-1, 1)
|
|
if new_page < self.show_page then
|
|
self.show_page = new_page
|
|
self:_populateItems()
|
|
end
|
|
end
|
|
|
|
function KeyValuePage:goToPage(page)
|
|
self.show_page = page
|
|
self:_populateItems()
|
|
end
|
|
|
|
-- make sure self.item_margin and self.item_height are set before calling this
|
|
function KeyValuePage:_populateItems()
|
|
self.page_info:resetLayout()
|
|
self.main_content:clear()
|
|
local idx_offset = (self.show_page - 1) * self.items_per_page
|
|
for idx = 1, self.items_per_page do
|
|
local entry = self.kv_pairs[idx_offset + idx]
|
|
if entry == nil then break end
|
|
|
|
table.insert(self.main_content,
|
|
VerticalSpan:new{ width = self.item_margin })
|
|
if type(entry) == "table" then
|
|
table.insert(
|
|
self.main_content,
|
|
KeyValueItem:new{
|
|
height = self.item_height,
|
|
width = self.item_width,
|
|
key = entry[1],
|
|
value = entry[2],
|
|
callback = entry.callback,
|
|
callback_back = entry.callback_back,
|
|
textviewer_width = self.textviewer_width,
|
|
textviewer_height = self.textviewer_height,
|
|
value_overflow_align = self.value_overflow_align,
|
|
value_align = self.value_align,
|
|
show_parent = self,
|
|
}
|
|
)
|
|
elseif type(entry) == "string" then
|
|
local c = string.sub(entry, 1, 1)
|
|
if c == "-" then
|
|
table.insert(self.main_content,
|
|
VerticalSpan:new{ width = self.item_margin })
|
|
table.insert(self.main_content, LineWidget:new{
|
|
background = Blitbuffer.COLOR_LIGHT_GRAY,
|
|
dimen = Geom:new{
|
|
w = self.item_width,
|
|
h = Size.line.thick
|
|
},
|
|
style = "solid",
|
|
})
|
|
end
|
|
end
|
|
table.insert(self.main_content,
|
|
VerticalSpan:new{ width = self.item_margin })
|
|
end
|
|
self.page_info_text:setText(T(_("Page %1 of %2"), self.show_page, self.pages))
|
|
self.page_info_left_chev:showHide(self.pages > 1)
|
|
self.page_info_right_chev:showHide(self.pages > 1)
|
|
self.page_info_first_chev:showHide(self.pages > 2)
|
|
self.page_info_last_chev:showHide(self.pages > 2)
|
|
|
|
self.page_info_left_chev:enableDisable(self.show_page > 1)
|
|
self.page_info_right_chev:enableDisable(self.show_page < self.pages)
|
|
self.page_info_first_chev:enableDisable(self.show_page > 1)
|
|
self.page_info_last_chev:enableDisable(self.show_page < self.pages)
|
|
|
|
UIManager:setDirty(self, function()
|
|
return "ui", self.dimen
|
|
end)
|
|
end
|
|
|
|
function KeyValuePage:onNextPage()
|
|
self:nextPage()
|
|
return true
|
|
end
|
|
|
|
function KeyValuePage:onPrevPage()
|
|
self:prevPage()
|
|
return true
|
|
end
|
|
|
|
function KeyValuePage:onSwipe(arg, ges_ev)
|
|
if ges_ev.direction == "west" then
|
|
self:nextPage()
|
|
return true
|
|
elseif ges_ev.direction == "east" then
|
|
self:prevPage()
|
|
return true
|
|
elseif ges_ev.direction == "south" then
|
|
-- Allow easier closing with swipe down
|
|
self:onClose()
|
|
elseif ges_ev.direction == "north" then
|
|
-- no use for now
|
|
do end -- luacheck: ignore 541
|
|
else -- diagonal swipe
|
|
-- trigger full refresh
|
|
UIManager:setDirty(nil, "full")
|
|
-- a long diagonal swipe may also be used for taking a screenshot,
|
|
-- so let it propagate
|
|
return false
|
|
end
|
|
end
|
|
|
|
function KeyValuePage:onClose()
|
|
UIManager:close(self)
|
|
return true
|
|
end
|
|
|
|
function KeyValuePage:onReturn()
|
|
if self.callback_return then
|
|
self:callback_return()
|
|
UIManager:close(self)
|
|
UIManager:setDirty(nil, "ui")
|
|
end
|
|
end
|
|
|
|
return KeyValuePage
|