2
0
mirror of https://github.com/koreader/koreader synced 2024-11-16 06:12:56 +00:00
koreader/plugins/vocabbuilder.koplugin/main.lua

1545 lines
52 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

--[[--
This plugin processes dictionary word lookups and uses spaced repetition to help you remember new words.
@module koplugin.vocabbuilder
--]]--
local BD = require("ui/bidi")
local Blitbuffer = require("ffi/blitbuffer")
local BottomContainer = require("ui/widget/container/bottomcontainer")
local DB = require("db")
local Button = require("ui/widget/button")
local ButtonTable = require("ui/widget/buttontable")
local CenterContainer = require("ui/widget/container/centercontainer")
local ConfirmBox = require("ui/widget/confirmbox")
local Device = require("device")
local DictQuickLookUp = require("ui/widget/dictquicklookup")
local Event = require("ui/event")
local Font = require("ui/font")
local FocusManager = require("ui/widget/focusmanager")
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 IconButton = require("ui/widget/iconbutton")
local IconWidget = require("ui/widget/iconwidget")
local InputContainer = require("ui/widget/container/inputcontainer")
local LeftContainer = require("ui/widget/container/leftcontainer")
local LineWidget = require("ui/widget/linewidget")
local MovableContainer = require("ui/widget/container/movablecontainer")
local Notification = require("ui/widget/notification")
local RightContainer = require("ui/widget/container/rightcontainer")
local OverlapGroup = require("ui/widget/overlapgroup")
local Screen = Device.screen
local Size = require("ui/size")
local SortWidget = require("ui/widget/sortwidget")
local TextWidget = require("ui/widget/textwidget")
local TextBoxWidget = require("ui/widget/textboxwidget")
local TitleBar = require("ui/widget/titlebar")
local ToggleSwitch = require("ui/widget/toggleswitch")
local UIManager = require("ui/uimanager")
local VerticalGroup = require("ui/widget/verticalgroup")
local VerticalSpan = require("ui/widget/verticalspan")
local WidgetContainer = require("ui/widget/container/widgetcontainer")
local T = require("ffi/util").template
local _ = require("gettext")
local C_ = _.pgettext
-------- shared values
local word_face = Font:getFace("x_smallinfofont")
local subtitle_face = Font:getFace("cfont", 12)
local subtitle_italic_face = Font:getFace("NotoSans-Italic.ttf", 12)
local subtitle_color = Blitbuffer.COLOR_DARK_GRAY
local dim_color = Blitbuffer.Color8(0x22)
local settings = G_reader_settings:readSetting("vocabulary_builder", {enabled = true})
local function resetButtonOnLookupWindow()
if not settings.enabled then -- auto add words
DictQuickLookUp.tweak_buttons_func = function(obj, buttons)
if obj.is_wiki_fullpage then
return
elseif obj.is_wiki then
-- make wiki window have the same button_tweak as its presenting dictionary window
local widget = UIManager:getSecondTopmostWidget()
if widget.tweak_buttons_func then
widget:tweak_buttons_func(buttons)
end
return
end
table.insert(buttons, 1, {{
id = "vocabulary",
text = _("Add to vocabulary builder"),
font_bold = false,
callback = function()
local book_title = obj.ui.doc_settings and obj.ui.doc_settings:readSetting("doc_props").title or _("Dictionary lookup")
if book_title == "" then -- no or empty metadata title
if obj.ui.document and obj.ui.document.file then
local util = require("util")
local directory, filename = util.splitFilePathName(obj.ui.document.file) -- luacheck: no unused
book_title = util.splitFileNameSuffix(filename)
end
end
obj.ui:handleEvent(Event:new("WordLookedUp", obj.word, book_title, true)) -- is_manual: true
local button = obj.button_table.button_by_id["vocabulary"]
if button then
button:disable()
UIManager:setDirty(obj, function()
return "ui", button.dimen
end)
end
end
}})
end
else
DictQuickLookUp.tweak_buttons_func = nil
end
end
local function onShowFilter(widget)
local sort_items = {}
local book_data = DB:selectBooks()
local toggled = {}
for _, ifo in pairs(book_data) do
table.insert(sort_items, {
text = ifo.name or "",
callback = function()
ifo.filter = not ifo.filter
if toggled[ifo.id] then
toggled[ifo.id] = nil
else
toggled[ifo.id] = true
end
end,
checked_func = function()
return ifo.filter
end,
ifo = ifo,
})
end
local sort_widget = SortWidget:new{
title = _("Filter words from books"),
item_table = sort_items,
sort_disabled = true,
callback = function()
if #toggled then
DB:toggleBookFilter(toggled)
widget:reloadItems()
end
UIManager:setDirty(nil, "ui")
end
}
UIManager:show(sort_widget)
end
local function saveSettings()
G_reader_settings:saveSetting("vocabulary_builder", settings)
end
--[[--
Menu dialogue widget
--]]--
local MenuDialog = FocusManager:extend{
padding = Size.padding.large,
is_edit_mode = false,
edit_callback = nil,
tap_close_callback = nil,
clean_callback = nil,
reset_callback = nil,
}
function MenuDialog:init()
self.layout = {}
if Device:hasKeys() then
self.key_events.Close = { { Device.input.group.Back }, doc = "close dialog" }
end
if Device:isTouchDevice() then
self.ges_events.Tap = {
GestureRange:new {
ges = "tap",
range = Geom:new {
x = 0,
y = 0,
w = Screen:getWidth(),
h = Screen:getHeight(),
}
}
}
end
local size = Screen:getSize()
local width = math.floor(size.w * 0.8)
-- Switch text translations could be long
local temp_text_widget = TextWidget:new{
text = _("Auto add new words"),
face = Font:getFace("xx_smallinfofont")
}
local switch_guide_width = temp_text_widget:getSize().w
temp_text_widget:setText(_("Save context"))
switch_guide_width = math.max(switch_guide_width, temp_text_widget:getSize().w)
switch_guide_width = math.min(math.max(switch_guide_width, math.ceil(width*0.39)), math.ceil(width*0.61))
temp_text_widget:free()
local switch_width = width - switch_guide_width - Size.padding.fullscreen - Size.padding.default
local switch = ToggleSwitch:new{
width = switch_width,
default_value = 2,
name = "vocabulary_builder",
name_text = nil, --_("Accept new words"),
event = "ChangeEnableStatus",
args = {"off", "on"},
default_arg = "on",
toggle = { _("off"), _("on") },
values = {1, 2},
alternate = false,
enabled = true,
config = self,
readonly = self.readonly,
}
switch:setPosition(settings.enabled and 2 or 1)
self:mergeLayoutInVertical(switch)
self.context_switch = ToggleSwitch:new{
width = switch_width,
default_value = 1,
name_text = nil,
event = "ChangeContextStatus",
args = {"off", "on"},
default_arg = "off",
toggle = { _("off"), _("on") },
values = {1, 2},
alternate = false,
enabled = true,
config = self,
readonly = self.readonly,
}
self.context_switch:setPosition(settings.with_context and 2 or 1)
self:mergeLayoutInVertical(self.context_switch)
local filter_button = {
text = _("Filter books"),
callback = function()
self:onClose()
onShowFilter(self.show_parent)
end
}
local reverse_button = {
text = settings.reverse and _("Reverse order") or _("Reverse order and show only reviewable"),
callback = function()
self:onClose()
settings.reverse = not settings.reverse
saveSettings()
self.show_parent:reloadItems()
end
}
local edit_button = {
text = self.is_edit_mode and _("Resume") or _("Quick deletion"),
callback = function()
self:onClose()
self.edit_callback()
end
}
local reset_button = {
text = _("Reset all progress"),
callback = function()
UIManager:show(ConfirmBox:new{
text = _("Reset progress of all words?"),
ok_text = _("Reset"),
ok_callback = function()
DB:resetProgress()
self:onClose()
self.reset_callback()
end
})
end
}
local clean_button = {
text = _("Clean all words"),
callback = function()
UIManager:show(ConfirmBox:new{
text = _("Clean all words including progress?"),
ok_text = _("Clean"),
ok_callback = function()
DB:purge()
self:onClose()
self.clean_callback()
end
})
end,
}
local buttons = ButtonTable:new{
width = width,
buttons = {
{filter_button},
{reverse_button},
{edit_button},
{reset_button},
{clean_button}
},
show_parent = self
}
self:mergeLayoutInVertical(buttons)
self.covers_fullscreen = true
self[1] = CenterContainer:new{
dimen = size,
FrameContainer:new{
padding = Size.padding.default,
padding_top = Size.padding.large,
padding_bottom = 0,
background = Blitbuffer.COLOR_WHITE,
bordersize = Size.border.window,
radius = Size.radius.window,
VerticalGroup:new{
HorizontalGroup:new{
RightContainer:new{
dimen = Geom:new{w = switch_guide_width, h = switch:getSize().h },
TextWidget:new{
text = _("Auto add new words"),
face = Font:getFace("xx_smallinfofont"),
max_width = switch_guide_width
}
},
HorizontalSpan:new{width = Size.padding.fullscreen},
switch,
},
VerticalSpan:new{ width = Size.padding.default},
HorizontalGroup:new{
RightContainer:new{
dimen = Geom:new{w = switch_guide_width, h = switch:getSize().h},
TextWidget:new{
text = _("Save context"),
face = Font:getFace("xx_smallinfofont"),
max_width = switch_guide_width
}
},
HorizontalSpan:new{width = Size.padding.fullscreen},
self.context_switch,
},
VerticalSpan:new{ width = Size.padding.large},
LineWidget:new{
background = Blitbuffer.COLOR_GRAY,
dimen = Geom:new{
w = width,
h = Screen:scaleBySize(1),
}
},
buttons
}
}
}
end
function MenuDialog:onShow()
UIManager:setDirty(self, function()
return "flashui", self[1][1].dimen
end)
end
function MenuDialog:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self[1][1].dimen
end)
end
function MenuDialog:onTap(_, ges)
if ges.pos:notIntersectWith(self[1][1].dimen) then
-- Tap outside closes widget
self:onClose()
return true
end
end
function MenuDialog:onClose()
UIManager:close(self)
if self.tap_close_callback then
self.tap_close_callback()
end
return true
end
function MenuDialog:onChangeContextStatus(args, position)
settings.with_context = position == 2
saveSettings()
end
function MenuDialog:onChangeEnableStatus(args, position)
settings.enabled = position == 2
saveSettings()
resetButtonOnLookupWindow()
end
function MenuDialog:onConfigChoose(values, name, event, args, position)
UIManager:tickAfterNext(function()
if values then
if event == "ChangeEnableStatus" then
self:onChangeEnableStatus(args, position)
elseif event == "ChangeContextStatus" then
self:onChangeContextStatus(args, position)
end
end
UIManager:setDirty(nil, "ui", nil, true)
end)
end
--[[--
Individual word info dialogue widget
--]]--
local WordInfoDialog = InputContainer:extend{
title = nil,
book_title = nil,
dates = nil,
padding = Size.padding.large,
margin = Size.margin.title,
tap_close_callback = nil,
remove_callback = nil,
reset_callback = nil,
dismissable = true, -- set to false if any button callback is required
}
local word_info_dialog_width
function WordInfoDialog:init()
if self.dismissable then
if Device:hasKeys() then
self.key_events.Close = { { Device.input.group.Back }, doc = "close dialog" }
end
if Device:isTouchDevice() then
self.ges_events.Tap = {
GestureRange:new {
ges = "tap",
range = Geom:new {
x = 0,
y = 0,
w = Screen:getWidth(),
h = Screen:getHeight(),
}
}
}
end
end
if not word_info_dialog_width then
local temp_text = TextWidget:new{
text = self.dates,
padding = Size.padding.fullscreen,
face = Font:getFace("cfont", 14)
}
local dates_width = temp_text:getSize().w
temp_text:free()
local screen_width = math.min(Screen:getWidth(), Screen:getHeight())
word_info_dialog_width = math.floor(math.max(screen_width * 0.6, math.min(screen_width * 0.8, dates_width)))
end
local width = word_info_dialog_width
local reset_button = {
text = _("Reset progress"),
callback = function()
self.reset_callback()
UIManager:close(self)
end
}
local remove_button = {
text = _("Remove word"),
callback = function()
self.remove_callback()
UIManager:close(self)
end
}
local buttons = {{reset_button, remove_button}}
if self.show_parent.item.last_due_time then
table.insert(buttons, {{
text = _("Undo study status"),
callback = function()
self.undo_callback()
UIManager:close(self)
end
}})
end
local focus_button = ButtonTable:new{
width = width,
buttons = buttons,
show_parent = self
}
local copy_button = Button:new{
text = "", -- copy in nerdfont,
callback = function()
Device.input.setClipboardText(self.title)
UIManager:show(Notification:new{
text = _("Word copied to clipboard."),
})
end,
bordersize = 0,
}
local has_context = self.prev_context or self.next_context
self[1] = CenterContainer:new{
dimen = Screen:getSize(),
MovableContainer:new{
FrameContainer:new{
VerticalGroup:new{
align = "center",
FrameContainer:new{
padding = self.padding,
padding_top = Size.padding.buttontable,
padding_bottom = Size.padding.buttontable,
margin = self.margin,
bordersize = 0,
VerticalGroup:new {
align = "left",
HorizontalGroup:new{
TextWidget:new{
text = self.title,
max_width = width - copy_button:getSize().w - Size.padding.default,
face = word_face,
bold = true,
alignment = self.title_align or "left",
},
HorizontalSpan:new{ width=Size.padding.default },
copy_button,
},
TextBoxWidget:new{
text = self.book_title,
width = width,
face = Font:getFace("NotoSans-Italic.ttf", 15),
alignment = self.title_align or "left",
},
VerticalSpan:new{width= Size.padding.default},
has_context and
TextBoxWidget:new{
text = "..." .. self.prev_context:gsub("\n", " ") .. "" ..self.title.."" .. self.next_context:gsub("\n", " ") .. "...",
width = width,
face = Font:getFace("smallffont"),
alignment = self.title_align or "left",
}
or VerticalSpan:new{ width = Size.padding.default },
VerticalSpan:new{ width = has_context and Size.padding.default or 0},
TextBoxWidget:new{
text = self.dates,
width = width,
face = Font:getFace("cfont", 14),
alignment = self.title_align or "left",
fgcolor = dim_color
},
}
},
LineWidget:new{
background = Blitbuffer.COLOR_GRAY,
dimen = Geom:new{
w = width + self.padding + self.margin,
h = Screen:scaleBySize(1),
}
},
focus_button
},
background = Blitbuffer.COLOR_WHITE,
bordersize = Size.border.window,
radius = Size.radius.window,
padding = 0
}
}
}
end
function WordInfoDialog:setTitle(title)
self.title = title
self:free()
self:init()
UIManager:setDirty("all", "ui")
end
function WordInfoDialog:onShow()
UIManager:setDirty(self, function()
return "flashui", self[1][1].dimen
end)
end
function WordInfoDialog:onCloseWidget()
UIManager:setDirty(nil, function()
return "ui", self[1][1].dimen
end)
end
function WordInfoDialog:onClose()
UIManager:close(self)
if self.tap_close_callback then
self.tap_close_callback()
end
return true
end
function WordInfoDialog:onTap(_, ges)
if ges.pos:notIntersectWith(self[1][1].dimen) then
-- Tap outside closes widget
self:onClose()
return true
end
end
function WordInfoDialog:paintTo(...)
InputContainer.paintTo(self, ...)
self.dimen = self[1][1].dimen -- FrameContainer
end
-- values useful for item cells
local ellipsis_button_width = Screen:scaleBySize(34)
local star_width = Screen:scaleBySize(25)
local point_widget = TextWidget:new{
text = "",
bold = true,
face = Font:getFace("cfont", 24),
fgcolor = dim_color
}
--[[--
Individual word item widget
--]]--
local VocabItemWidget = InputContainer:extend{
face = Font:getFace("smallinfofont"),
width = nil,
height = nil,
review_button_width = nil,
show_parent = nil,
item = nil,
forgot_button = nil,
got_it_button = nil,
more_button = nil,
layout = nil
}
--[[--
item: {
checked_func: Block,
review_count: interger,
word: Text
book_title: TEXT
create_time: Integer
review_time: Integer
due_time: Integer,
got_it_callback: function
remove_callback: function
is_dim: BOOL
}
--]]--
local point_widget_height = point_widget:getSize().h
local point_widget_width = point_widget:getSize().w
local word_height = TextWidget:new{text = " ", face = word_face}:getSize().h
local subtitle_height = TextWidget:new{text = " ", face = subtitle_face}:getSize().h
function VocabItemWidget:init()
self.layout = {}
self.dimen = Geom:new{w = self.width, h = self.height}
self.ges_events.Tap = {
GestureRange:new{
ges = "tap",
range = self.dimen,
}
}
self.ges_events.Hold = {
GestureRange:new{
ges = "hold",
range = self.dimen,
}
}
self.v_spacer = VerticalSpan:new{width = math.floor((self.height - word_height - subtitle_height)/2)}
self.point_v_spacer = VerticalSpan:new{width = (self.v_spacer.width + word_height/2) - point_widget_height/2 }
self.margin_span = HorizontalSpan:new{ width = Size.padding.large }
self:initItemWidget()
end
function VocabItemWidget:initItemWidget()
for i = 1, #self.layout do self.layout[i] = nil end
if not self.show_parent.is_edit_mode then
self.more_button = Button:new{
text = (self.item.prev_context or self.item.next_context) and "" or "",
padding = Size.padding.button,
callback = function() self:showMore() end,
width = ellipsis_button_width,
bordersize = 0,
show_parent = self
}
else
self.more_button = IconButton:new{
icon = "exit",
width = star_width,
height = star_width,
padding = math.floor((ellipsis_button_width - star_width)/2) + Size.padding.button,
callback = function()
self:remover()
end,
}
end
local right_side_width
local right_widget
if not self.show_parent.is_edit_mode and self.item.due_time <= os.time() then
self.has_review_buttons = true
right_side_width = self.review_button_width * 2 + Size.padding.large * 2 + ellipsis_button_width
self.forgot_button = Button:new{
text = _("Forgot"),
width = self.review_button_width,
max_width = self.review_button_width,
radius = Size.radius.button,
callback = function()
self:onForgot()
end,
show_parent = self,
}
self.got_it_button = Button:new{
text = _("Got it"),
radius = Size.radius.button,
callback = function()
self:onGotIt()
end,
width = self.review_button_width,
max_width = self.review_button_width,
show_parent = self,
}
right_widget = HorizontalGroup:new{
dimen = Geom:new{ w = 0, h = self.height },
self.margin_span,
self.forgot_button,
self.margin_span,
self.got_it_button,
self.more_button,
}
table.insert(self.layout, self.forgot_button)
table.insert(self.layout, self.got_it_button)
table.insert(self.layout, self.more_button)
else
self.has_review_buttons = false
local star = IconWidget:new{
icon = "check",
width = star_width,
height = star_width,
dim = true
}
if self.item.review_count > 6 then
right_side_width = Size.padding.large * 4 + 9 * (star:getSize().w)
right_widget = HorizontalGroup:new {
dimen = Geom:new{w=0, h = self.height}
}
for i=1, 6, 1 do
table.insert(right_widget, star)
end
table.insert(right_widget,
TextWidget:new {
text = " + ",
face = word_face,
fgcolor = Blitbuffer.COLOR_DARK_GRAY
}
)
table.insert(right_widget, star)
table.insert(right_widget,
TextWidget:new {
text = "× " .. self.item.review_count-6,
face = word_face,
fgcolor = Blitbuffer.COLOR_DARK_GRAY
}
)
elseif self.item.review_count > 0 then
right_side_width = Size.padding.large * 4 + self.item.review_count * (star:getSize().w)
right_widget = HorizontalGroup:new {
dimen = Geom:new{w=0, h = self.height}
}
for i=1, self.item.review_count, 1 do
table.insert(right_widget, star)
end
else
star:free()
right_side_width = Size.padding.large * 4
right_widget = HorizontalGroup:new{
dimen = Geom:new{w=0, h = self.height},
HorizontalSpan:new {width = Size.padding.default }
}
end
table.insert(right_widget, self.margin_span)
table.insert(right_widget, self.more_button)
table.insert(self.layout, self.more_button)
end
local text_max_width = self.width - point_widget_width - right_side_width
local subtitle_prefix = TextWidget:new{
text = self:getTimeSinceDue() .. _("From") .. " ",
face = subtitle_face,
fgcolor = subtitle_color
}
local word_widget = Button:new{
text = self.item.word,
bordersize = 0,
callback = function() self.item.callback(self.item) end,
padding = 0,
max_width = math.ceil(math.max(5,text_max_width - Size.padding.fullscreen))
}
word_widget.label_widget.fgcolor = self.item.is_dim and dim_color or Blitbuffer.COLOR_BLACK
table.insert(self.layout, 1, word_widget)
self[1] = FrameContainer:new{
padding = 0,
bordersize = 0,
HorizontalGroup:new{
dimen = Geom:new{
w = self.width,
h = self.height,
},
HorizontalGroup:new{
dimen = Geom:new{
w = self.width - right_side_width,
h = self.height,
},
VerticalGroup:new{
dimen = Geom:new{w = point_widget_width, h = self.height},
self.point_v_spacer,
point_widget,
VerticalSpan:new { width = self.height - point_widget_height - self.point_v_spacer.width}
},
VerticalGroup:new{
dimen = Geom:new{
w = text_max_width,
h = self.height,
},
self.v_spacer,
LeftContainer:new{
dimen = Geom:new{w = text_max_width, h = word_height},
word_widget
},
LeftContainer:new{
dimen = Geom:new{w = text_max_width, h = math.floor(self.height - word_height - self.v_spacer.width*2.2)},
HorizontalGroup:new{
subtitle_prefix,
TextWidget:new{
text = self.item.book_title,
face = subtitle_italic_face,
max_width = math.ceil(math.max(5,text_max_width - subtitle_prefix:getSize().w - Size.padding.fullscreen)),
fgcolor = subtitle_color
}
}
},
self.v_spacer
}
},
RightContainer:new{
dimen = Geom:new{ w = right_side_width+Size.padding.default, h = self.height},
right_widget
}
},
}
end
function VocabItemWidget:getTimeSinceDue()
local elapsed = os.time() - self.item.due_time
local abs = math.abs(elapsed)
local readable_time
local rounding = elapsed > 0 and math.floor or math.ceil
if abs < 60 then
readable_time = abs .. C_("Time", "s")
elseif abs < 3600 then
readable_time = string.format("%d"..C_("Time", "m"), rounding(abs/60))
elseif abs < 3600 * 24 then
readable_time = string.format("%d"..C_("Time", "h"), rounding(abs/3600))
else
readable_time = string.format("%d"..C_("Time", "d"), rounding(abs/3600/24))
end
if elapsed < 0 then
return "" .. readable_time .. " | " --hourglass
else
return readable_time .. " | "
end
end
function VocabItemWidget:remover()
self.item.remove_callback(self.item)
self.show_parent:removeAt(self.index)
end
function VocabItemWidget:resetProgress()
self.item.review_count = 0
self.item.due_time = os.time()
self.item.review_time = self.item.due_time
self.item.last_due_time = nil
self:initItemWidget()
UIManager:setDirty(self.show_parent, function()
return "ui", self[1].dimen end)
end
function VocabItemWidget:undo()
self.item.streak_count = self.item.last_streak_count or self.item.streak_count
self.item.review_count = self.item.last_review_count or self.item.review_count
self.item.review_time = self.item.last_review_time
self.item.due_time = self.item.last_due_time or self.item.due_time
self.item.last_streak_count = nil
self.item.last_review_count = nil
self.item.last_review_time = nil
self.item.last_due_time = nil
self:initItemWidget()
UIManager:setDirty(self.show_parent, function()
return "ui", self[1].dimen end)
end
function VocabItemWidget:removeAndClose()
self:remover()
UIManager:close(self.dialogue)
end
function VocabItemWidget:showMore()
local dialogue = WordInfoDialog:new{
title = self.item.word,
book_title = self.item.book_title,
dates = _("Added on") .. " " .. os.date("%Y-%m-%d", self.item.create_time) .. " | " ..
_("Review scheduled at") .. " " .. os.date("%Y-%m-%d %H:%M", self.item.due_time),
prev_context = self.item.prev_context,
next_context = self.item.next_context,
remove_callback = function()
self:remover()
end,
reset_callback = function()
self:resetProgress()
end,
undo_callback = function()
self:undo()
end,
show_parent = self
}
UIManager:show(dialogue)
end
function VocabItemWidget:onTap(_, ges)
if self.has_review_buttons then
if ges.pos.x > self.forgot_button.dimen.x and ges.pos.x < self.forgot_button.dimen.x + self.forgot_button.dimen.w then
self:onForgot()
elseif ges.pos.x > self.got_it_button.dimen.x and ges.pos.x < self.got_it_button.dimen.x + self.got_it_button.dimen.w then
self:onGotIt()
elseif ges.pos.x > self.more_button.dimen.x and ges.pos.x < self.more_button.dimen.x + self.more_button.dimen.w then
self:showMore()
elseif self.item.callback then
self.item.callback(self.item)
end
else
if BD.mirroredUILayout() then
if ges.pos.x > self.more_button.dimen.x and ges.pos.x < self.more_button.dimen.x + self.more_button.dimen.w * 2 then
if self.show_parent.is_edit_mode then
self:remover()
else
self:showMore()
end
elseif self.item.callback then
self.item.callback(self.item)
end
else
if ges.pos.x > self.more_button.dimen.x - self.more_button.dimen.w and ges.pos.x < self.more_button.dimen.x + self.more_button.dimen.w then
if self.show_parent.is_edit_mode then
self:remover()
else
self:showMore()
end
elseif self.item.callback then
self.item.callback(self.item)
end
end
end
return true
end
function VocabItemWidget:onHold(_, ges)
self:onTap(_, ges)
return true
end
function VocabItemWidget:onGotIt()
self.item.got_it_callback(self.item)
self.item.is_dim = true
self:initItemWidget()
if self.show_parent.selected.x == 3 then
self.show_parent.selected.x = 1
end
UIManager:setDirty(self.show_parent, function()
return "ui", self[1].dimen end)
end
function VocabItemWidget:onForgot(no_lookup)
self.item.forgot_callback(self.item)
self.item.is_dim = false
self:initItemWidget()
UIManager:setDirty(self.show_parent, function()
return "ui", self[1].dimen end)
if not no_lookup and self.item.callback then
self.item.callback(self.item)
end
end
--[[--
Container widget. Same as sortwidget
--]]--
local VocabularyBuilderWidget = FocusManager:extend{
title = "",
width = nil,
height = nil,
-- index for the first item to show
show_page = 1,
-- table of items
item_table = nil, -- mandatory (array)
is_edit_mode = false,
callback = nil,
}
function VocabularyBuilderWidget:init()
self.item_table = self:reload_items_callback()
self.layout = {}
self.dimen = Geom:new{
w = self.width or Screen:getWidth(),
h = self.height or Screen:getHeight(),
}
if Device:hasKeys() then
self.key_events.Close = { { Device.input.group.Back }, doc = "close dialog" }
self.key_events.NextPage = { { Device.input.group.PgFwd}, doc = "next page"}
self.key_events.PrevPage = { { Device.input.group.PgBack}, doc = "prev page"}
end
if Device:isTouchDevice() then
self.ges_events.Swipe = {
GestureRange:new{
ges = "swipe",
range = self.dimen,
}
}
self.ges_events.MultiSwipe = {
GestureRange:new{
ges = "multiswipe",
range = function() return self.dimen end,
}
}
end
local padding = Size.padding.large
self.width_widget = self.dimen.w - 2 * padding
self.item_width = self.dimen.w - 2 * padding
self.footer_center_width = math.floor(self.width_widget * (32/100))
self.footer_button_width = math.floor(self.width_widget * (12/100))
-- group for footer
local chevron_left = "chevron.left"
local chevron_right = "chevron.right"
local chevron_first = "chevron.first"
local chevron_last = "chevron.last"
if BD.mirroredUILayout() then
chevron_left, chevron_right = chevron_right, chevron_left
chevron_first, chevron_last = chevron_last, chevron_first
end
self.footer_left = Button:new{
icon = chevron_left,
width = self.footer_button_width,
callback = function() self:prevPage() end,
bordersize = 0,
radius = 0,
show_parent = self,
}
self.footer_right = Button:new{
icon = chevron_right,
width = self.footer_button_width,
callback = function() self:nextPage() end,
bordersize = 0,
radius = 0,
show_parent = self,
}
self.footer_first_up = Button:new{
icon = chevron_first,
width = self.footer_button_width,
callback = function()
self:goToPage(1)
end,
bordersize = 0,
radius = 0,
show_parent = self,
}
self.footer_last_down = Button:new{
icon = chevron_last,
width = self.footer_button_width,
callback = function()
self:goToPage(self.pages)
end,
bordersize = 0,
radius = 0,
show_parent = self,
}
self.footer_page = Button:new{
text = "",
hold_input = {
title = _("Enter page number"),
hint_func = function()
return "(" .. "1 - " .. self.pages .. ")"
end,
type = "number",
deny_blank_input = true,
callback = function(input)
local page = tonumber(input)
if page and page >= 1 and page <= self.pages then
self:goToPage(page)
end
end,
ok_text = _("Go to page"),
},
call_hold_input_on_tap = true,
bordersize = 0,
margin = 0,
text_font_face = "pgfont",
text_font_bold = false,
width = self.footer_center_width,
show_parent = self,
}
self.page_info = HorizontalGroup:new{
self.footer_first_up,
self.footer_left,
self.footer_page,
self.footer_right,
self.footer_last_down,
}
local bottom_line = LineWidget:new{
dimen = Geom:new{ w = self.item_width, h = Size.line.thick },
background = Blitbuffer.COLOR_LIGHT_GRAY,
}
local vertical_footer = VerticalGroup:new{
bottom_line,
self.page_info,
}
self.footer_height = vertical_footer:getSize().h
local footer = BottomContainer:new{
dimen = self.dimen:copy(),
vertical_footer,
}
-- setup title bar
self.title_bar = TitleBar:new{
width = self.dimen.w,
align = "center",
title_face = Font:getFace("smallinfofontbold"),
bottom_line_color = Blitbuffer.COLOR_LIGHT_GRAY,
with_bottom_line = true,
bottom_line_h_padding = padding,
left_icon = "appbar.menu",
left_icon_tap_callback = function() self:showMenu() end,
title = self.title,
close_callback = function() self:onClose() end,
show_parent = self,
}
self:setupItemHeight()
self.main_content = VerticalGroup:new{}
-- calculate item's review button width once
local temp_button = Button:new{
text = _("Got it"),
padding_h = Size.padding.large
}
self.review_button_width = temp_button:getSize().w
temp_button:setText(_("Forgot"))
self.review_button_width = math.min(math.max(self.review_button_width, temp_button:getSize().w), Screen:getWidth()/4)
temp_button:free()
self:_populateItems()
local frame_content = FrameContainer:new{
height = self.dimen.h,
padding = 0,
bordersize = 0,
background = Blitbuffer.COLOR_WHITE,
VerticalGroup:new{
self.title_bar,
self.main_content,
},
}
local content = OverlapGroup:new{
dimen = self.dimen:copy(),
frame_content,
footer,
}
-- assemble page
self[1] = FrameContainer:new{
height = self.dimen.h,
padding = 0,
bordersize = 0,
background = Blitbuffer.COLOR_WHITE,
content
}
end
function VocabularyBuilderWidget:setupItemHeight()
local item_height = Screen:scaleBySize(self.is_edit_mode and 54 or 72)
self.item_height = item_height
self.item_margin = math.floor(self.item_height / 8)
local line_height = self.item_height + self.item_margin
local content_height = self.dimen.h - self.title_bar:getHeight() - self.footer_height - Size.padding.large
self.items_per_page = math.floor(content_height / line_height)
self.item_margin = self.item_margin + math.floor((content_height - self.items_per_page * line_height ) / self.items_per_page )
self.pages = math.ceil(#self.item_table / self.items_per_page)
self.show_page = math.max(1, math.min(self.pages, self.show_page))
end
function VocabularyBuilderWidget:nextPage()
local new_page = self.show_page == self.pages and 1 or self.show_page + 1
self.show_page = new_page
self:_populateItems()
end
function VocabularyBuilderWidget:prevPage()
local new_page = self.show_page == 1 and self.pages or self.show_page - 1
self.show_page = new_page
self:_populateItems()
end
function VocabularyBuilderWidget:goToPage(page)
self.show_page = page
self:_populateItems()
end
function VocabularyBuilderWidget:moveItem(diff)
local move_to = diff
if move_to > 0 and move_to <= #self.item_table then
self.show_page = math.ceil(move_to / self.items_per_page)
self:_populateItems()
end
end
function VocabularyBuilderWidget:removeAt(index)
if index > #self.item_table then return end
table.remove(self.item_table, index)
self.show_page = math.ceil(math.min(index, #self.item_table) / self.items_per_page)
self.pages = math.ceil(#self.item_table / self.items_per_page)
self:_populateItems()
end
-- make sure self.item_margin and self.item_height are set before calling this
function VocabularyBuilderWidget:_populateItems()
self.main_content:clear()
self.layout = {{self.title_bar.left_button, self.title_bar.right_button}} -- title
local idx_offset = (self.show_page - 1) * self.items_per_page
local page_last
if idx_offset + self.items_per_page <= #self.item_table then
page_last = idx_offset + self.items_per_page
else
page_last = #self.item_table
end
if self.select_items_callback then
self:select_items_callback(idx_offset, page_last)
end
for idx = idx_offset + 1, page_last do
table.insert(self.main_content, VerticalSpan:new{ width = self.item_margin / (idx == idx_offset+1 and 2 or 1) })
if #self.item_table == 0 or not self.item_table[idx].word then break end
local item = VocabItemWidget:new{
height = self.item_height,
width = self.item_width,
review_button_width = self.review_button_width,
item = self.item_table[idx],
index = idx,
show_parent = self,
}
table.insert(self.layout, #self.layout, item.layout)
table.insert(
self.main_content,
item
)
end
if #self.main_content == 0 then
table.insert(self.main_content, HorizontalSpan:new{width = self.item_width})
end
self.footer_page:setText(T(_("Page %1 of %2"), self.show_page, self.pages), self.footer_center_width)
if self.pages > 1 then
self.footer_page:enable()
else
self.footer_page:disableWithoutDimming()
end
if self.pages == 0 then
local has_filtered_book = DB:hasFilteredBook()
local text = has_filtered_book and _("Filter in effect") or
self:check_reverse() and _("No reviewable items") or _("No items")
self.footer_page:setText(text, self.footer_center_width)
self.footer_first_up:hide()
self.footer_last_down:hide()
self.footer_left:hide()
self.footer_right:hide()
elseif self.footer_left.hidden then
self.footer_first_up:show()
self.footer_last_down:show()
self.footer_left:show()
self.footer_right:show()
end
local chevron_first = "chevron.first"
local chevron_last = "chevron.last"
if BD.mirroredUILayout() then
chevron_first, chevron_last = chevron_last, chevron_first
end
self.footer_first_up:setIcon(chevron_first, self.footer_button_width)
self.footer_last_down:setIcon(chevron_last, self.footer_button_width)
self.footer_left:enableDisable(self.show_page > 1)
self.footer_right:enableDisable(self.show_page < self.pages)
self.footer_first_up:enableDisable(self.show_page > 1)
self.footer_last_down:enableDisable(self.show_page < self.pages)
if not self.layout[self.selected.y] or not self.layout[self.selected.y][self.selected.x] then
self.selected = {x=1, y=1}
end
UIManager:setDirty(self, function()
return "ui", self.dimen
end)
end
function VocabularyBuilderWidget:gotItFromDict(word)
for i = 1, #self.main_content, 1 do
if self.main_content[i].item and self.main_content[i].item.word == word then
self.main_content[i]:onGotIt()
return
end
end
end
function VocabularyBuilderWidget:forgotFromDict(word)
for i = 1, #self.main_content, 1 do
if self.main_content[i].item and self.main_content[i].item.word == word then
self.main_content[i]:onForgot(true)
return
end
end
end
function VocabularyBuilderWidget:resetItems()
for i, item in ipairs(self.item_table) do
if self.item_table[i].word then -- selected from DB
self.item_table[i] = {
callback = self.item_table[i].callback
}
end
end
self:_populateItems()
end
function VocabularyBuilderWidget:showMenu()
UIManager:show(MenuDialog:new{
is_edit_mode = self.is_edit_mode,
edit_callback = function()
self.is_edit_mode = not self.is_edit_mode
self:setupItemHeight()
self:_populateItems()
end,
clean_callback = function()
self.item_table = {}
self.pages = 0
self:_populateItems()
end,
reset_callback = function()
self:resetItems()
end,
show_parent = self
})
end
function VocabularyBuilderWidget:check_reverse()
return settings.reverse
end
function VocabularyBuilderWidget:reloadItems()
DB:batchUpdateItems(self.item_table)
self.item_table = self:reload_items_callback()
self.pages = math.ceil(#self.item_table / self.items_per_page)
self:goToPage(1)
end
function VocabularyBuilderWidget:onShow()
UIManager:setDirty(self, "flashui")
end
function VocabularyBuilderWidget:onNextPage()
self:nextPage()
return true
end
function VocabularyBuilderWidget:onPrevPage()
self:prevPage()
return true
end
function VocabularyBuilderWidget:onSwipe(arg, ges_ev)
local direction = BD.flipDirectionIfMirroredUILayout(ges_ev.direction)
if direction == "west" then
self:onNextPage()
elseif direction == "east" then
self:onPrevPage()
elseif direction == "south" then
-- Allow easier closing with swipe down
self:onClose()
elseif direction == "north" then
-- open filter
onShowFilter(self)
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 VocabularyBuilderWidget:onMultiSwipe(arg, ges_ev)
-- For consistency with other fullscreen widgets where swipe south can't be
-- used to close and where we then allow any multiswipe to close, allow any
-- multiswipe to close this widget too.
self:onClose()
return true
end
function VocabularyBuilderWidget:onClose()
DB:batchUpdateItems(self.item_table)
UIManager:close(self)
-- UIManager:setDirty(self, "ui")
return true
end
function VocabularyBuilderWidget:onCancel()
self:goToPage(self.show_page)
return true
end
function VocabularyBuilderWidget:onReturn()
return self:onClose()
end
--[[--
Item shown in main menu
--]]--
local VocabBuilder = WidgetContainer:extend{
name = "vocabulary_builder",
is_doc_only = false
}
function VocabBuilder:init()
self.ui.menu:registerToMainMenu(self)
end
function VocabBuilder:addToMainMenu(menu_items)
menu_items.vocabbuilder = {
text = _("Vocabulary builder"),
callback = function()
local reload_items = function(builder_widget)
builder_widget.reload_time = os.time()
local vocab_items = {}
for i = 1, DB:selectCount(builder_widget) do
table.insert(vocab_items, {
callback = function(item)
-- custom button table
local tweak_buttons_func = function() end
if item.due_time <= os.time() then
tweak_buttons_func = function(obj, buttons)
local tweaked_button_count = 0
local early_break
for j = 1, #buttons do
for k = 1, #buttons[j] do
if buttons[j][k].id == "highlight" and not buttons[j][k].enabled then
buttons[j][k] = {
id = "got_it",
text = _("Got it"),
callback = function()
self.builder_widget:gotItFromDict(item.word)
UIManager:sendEvent(Event:new("Close"))
end
}
if tweaked_button_count == 1 then
early_break = true
break
end
tweaked_button_count = tweaked_button_count + 1
elseif buttons[j][k].id == "search" and not buttons[j][k].enabled then
buttons[j][k] = {
id = "forgot",
text = _("Forgot"),
callback = function()
self.builder_widget:forgotFromDict(item.word)
UIManager:sendEvent(Event:new("Close"))
end
}
if tweaked_button_count == 1 then
early_break = true
break
end
tweaked_button_count = tweaked_button_count + 1
end
end
if early_break then break end
end
end
end
builder_widget.current_lookup_word = item.word
self.ui:handleEvent(Event:new("LookupWord", item.word, true, nil, nil, nil, tweak_buttons_func))
end
})
end
return vocab_items
end
self.builder_widget = VocabularyBuilderWidget:new{
title = _("Vocabulary builder"),
select_items_callback = function(obj, start_idx, end_idx)
DB:select_items(obj, start_idx, end_idx)
end,
reload_items_callback = reload_items
}
UIManager:show(self.builder_widget)
end
}
end
-- Event sent by readerdictionary "WordLookedUp"
function VocabBuilder:onWordLookedUp(word, title, is_manual)
if not settings.enabled and not is_manual then return end
if self.builder_widget and self.builder_widget.current_lookup_word == word then return true end
local prev_context
local next_context
if settings.with_context and self.ui.highlight then
prev_context, next_context = self.ui.highlight:getSelectedWordContext(15)
end
DB:insertOrUpdate({
book_title = title,
time = os.time(),
word = word,
prev_context = prev_context,
next_context = next_context
})
return true
end
-- register button in readerdictionary
resetButtonOnLookupWindow()
return VocabBuilder