local BD = require("ui/bidi") local Blitbuffer = require("ffi/blitbuffer") local ButtonTable = require("ui/widget/buttontable") local CenterContainer = require("ui/widget/container/centercontainer") local CloseButton = require("ui/widget/closebutton") local Device = require("device") local Geom = require("ui/geometry") local Event = require("ui/event") local Font = require("ui/font") local FrameContainer = require("ui/widget/container/framecontainer") local GestureRange = require("ui/gesturerange") local IconButton = require("ui/widget/iconbutton") local InputContainer = require("ui/widget/container/inputcontainer") local InputDialog = require("ui/widget/inputdialog") local LineWidget = require("ui/widget/linewidget") local Math = require("optmath") local MovableContainer = require("ui/widget/container/movablecontainer") local OverlapGroup = require("ui/widget/overlapgroup") local ScrollHtmlWidget = require("ui/widget/scrollhtmlwidget") local ScrollTextWidget = require("ui/widget/scrolltextwidget") local Size = require("ui/size") local TextWidget = require("ui/widget/textwidget") local TimeVal = require("ui/timeval") local Translator = require("ui/translator") 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 logger = require("logger") local util = require("util") local _ = require("gettext") local C_ = _.pgettext local Input = Device.input local Screen = Device.screen local T = require("ffi/util").template --[[ Display quick lookup word definition ]] local DictQuickLookup = InputContainer:new{ results = nil, lookupword = nil, dictionary = nil, definition = nil, displayword = nil, images = nil, is_wiki = false, is_wiki_fullpage = false, is_html = false, dict_index = 1, width = nil, height = nil, -- box of highlighted word, quick lookup window tries to not hide the word word_box = nil, -- refresh_callback will be called before we trigger full refresh in onSwipe refresh_callback = nil, html_dictionary_link_tapped_callback = nil, } local highlight_strings = { highlight =_("Highlight"), unhighlight = _("Unhighlight"), } function DictQuickLookup:canSearch() if self:isDocless() then return false end if self.is_wiki then -- In the Wiki variant of this widget, the Search button is coopted to cycle between enabled languages. if #self.wiki_languages > 1 then return true end else -- This is to prevent an ineffective button when we're launched from the Reader's menu. if self.highlight then return true end end return false end function DictQuickLookup:init() self.dict_font_size = G_reader_settings:readSetting("dict_font_size") or 20 self.content_face = Font:getFace("cfont", self.dict_font_size) local font_size_alt = self.dict_font_size - 4 if font_size_alt < 8 then font_size_alt = 8 end self.image_alt_face = Font:getFace("cfont", font_size_alt) if Device:hasKeys() then self.key_events = { ReadPrevResult = {{Input.group.PgBack}, doc = "read prev result"}, ReadNextResult = {{Input.group.PgFwd}, doc = "read next result"}, Close = { {"Back"}, doc = "close quick lookup" } } end if Device:isTouchDevice() then local range = Geom:new{ x = 0, y = 0, w = Screen:getWidth(), h = Screen:getHeight(), } self.ges_events = { Tap = { GestureRange:new{ ges = "tap", range = range, }, }, Swipe = { GestureRange:new{ ges = "swipe", range = range, }, }, -- This was for selection of a single word with simple hold -- HoldWord = { -- GestureRange:new{ -- ges = "hold", -- range = function() -- return self.region -- end, -- }, -- -- callback function when HoldWord is handled as args -- args = function(word) -- self.ui:handleEvent( -- -- don't pass self.highlight to subsequent lookup, we want -- -- the first to be the only one to unhighlight selection -- -- when closed -- Event:new("LookupWord", word, self.word_box)) -- end -- }, -- Allow selection of one or more words (see textboxwidget.lua) : HoldStartText = { GestureRange:new{ ges = "hold", range = range, }, }, HoldPanText = { GestureRange:new{ ges = "hold", range = range, }, }, HoldReleaseText = { GestureRange:new{ ges = "hold_release", range = range, }, -- callback function when HoldReleaseText is handled as args args = function(text, hold_duration) local lookup_target if hold_duration < TimeVal:new{ sec = 3, usec = 0 } then -- do this lookup in the same domain (dict/wikipedia) lookup_target = self.is_wiki and "LookupWikipedia" or "LookupWord" else -- but allow switching domain with a long hold lookup_target = self.is_wiki and "LookupWord" or "LookupWikipedia" end if lookup_target == "LookupWikipedia" then self:resyncWikiLanguages() end self.ui:handleEvent( -- don't pass self.highlight to subsequent lookup, we want -- the first to be the only one to unhighlight selection -- when closed Event:new(lookup_target, text) ) end }, -- These will be forwarded to MovableContainer after some checks ForwardingTouch = { GestureRange:new{ ges = "touch", range = range, }, }, ForwardingPan = { GestureRange:new{ ges = "pan", range = range, }, }, ForwardingPanRelease = { GestureRange:new{ ges = "pan_release", range = range, }, }, } end -- We no longer support setting a default dict with Tap on title. -- self:changeToDefaultDict() -- Now, dictionaries can be ordered (although not yet per-book), so trust the order set self:changeDictionary(1, true) -- don't call update -- And here comes the initial widget layout... if self.is_wiki then -- Keep a copy of self.wiki_languages for use -- by DictQuickLookup:resyncWikiLanguages() self.wiki_languages_copy = self.wiki_languages and {unpack(self.wiki_languages)} or nil end -- Bigger window if fullpage Wikipedia article being shown, -- or when large windows for dict requested local is_large_window = self.is_wiki_fullpage or G_reader_settings:isTrue("dict_largewindow") if is_large_window then self.width = Screen:getWidth() - 2*Size.margin.default else self.width = Screen:getWidth() - Screen:scaleBySize(80) end local frame_bordersize = Size.border.window local inner_width = self.width - 2*frame_bordersize -- Height will be computed below, after we build top an bottom -- components, when we know how much height they are taking. -- Dictionary title -- (a bit convoluted with margin & padding but no border, but let's -- do as other widgets to get the same look) local title_margin = Size.margin.title local title_padding = Size.padding.default local title_width = inner_width - 2*title_padding -2*title_margin local close_button = CloseButton:new{ window = self, padding_top = title_margin, } self.dict_title_text = TextWidget:new{ text = self.displaydictname, face = Font:getFace("x_smalltfont"), bold = true, max_width = title_width - close_button:getSize().w + close_button.padding_left -- Allow text to eat on the CloseButton padding_left (which -- is quite large to ensure a bigger tap area) } local dict_title_widget = self.dict_title_text if self.is_wiki then -- Visual hint: title left aligned for dict, but centered for Wikipedia dict_title_widget = CenterContainer:new{ dimen = Geom:new{ w = title_width, h = self.dict_title_text:getSize().h, }, self.dict_title_text, } end self.dict_title = FrameContainer:new{ margin = title_margin, bordersize = 0, padding = title_padding, dict_title_widget, } local title_bar = OverlapGroup:new{ dimen = { w = inner_width, h = self.dict_title:getSize().h }, self.dict_title, close_button, } local title_sep = LineWidget:new{ dimen = Geom:new{ w = inner_width, h = Size.line.thick, } } -- This padding and the resulting width apply to the content -- below the title: lookup word and definition local content_padding_h = Size.padding.large local content_padding_v = Size.padding.large -- added via VerticalSpan self.content_width = inner_width - 2*content_padding_h -- Spans between components local top_to_word_span = VerticalSpan:new{ width = content_padding_v } local word_to_definition_span = VerticalSpan:new{ width = content_padding_v } local definition_to_bottom_span = VerticalSpan:new{ width = content_padding_v } -- Lookup word local word_font_face = "tfont" -- Ensure this word doesn't get smaller than its definition local word_font_size = math.max(22, self.dict_font_size) -- Get the line height of the normal font size, as a base for sizing this component if not self.word_line_height then local test_widget = TextWidget:new{ text = "z", face = Font:getFace(word_font_face, word_font_size), } self.word_line_height = test_widget:getSize().h test_widget:free() end if self.is_wiki then -- Wikipedia has longer titles, so use a smaller font, word_font_size = math.max(18, self.dict_font_size) end local icon_size = Screen:scaleBySize(32) local lookup_height = math.max(self.word_line_height, icon_size) -- Edit button local lookup_edit_button = IconButton:new{ icon = "edit", width = icon_size, height = icon_size, padding = 0, padding_left = Size.padding.small, callback = function() -- allow adjusting the queried word self:lookupInputWord(self.word) end, hold_callback = function() -- allow adjusting the current result word self:lookupInputWord(self.lookupword) end, overlap_align = "right", show_parent = self, } local lookup_edit_button_w = lookup_edit_button:getSize().w -- Nb of results (if set) local lookup_word_nb local lookup_word_nb_w = 0 if self.displaynb then self.displaynb_text = TextWidget:new{ text = self.displaynb, face = Font:getFace("cfont", word_font_size), padding = 0, -- smaller height for better aligmnent with icon } lookup_word_nb = FrameContainer:new{ margin = 0, bordersize = 0, padding = 0, padding_left = Size.padding.small, padding_right = lookup_edit_button_w + Size.padding.default, overlap_align = "right", self.displaynb_text, } lookup_word_nb_w = lookup_word_nb:getSize().w end -- Lookup word self.lookup_word_text = TextWidget:new{ text = self.displayword, face = Font:getFace(word_font_face, word_font_size), bold = true, max_width = self.content_width - math.max(lookup_edit_button_w, lookup_word_nb_w), padding = 0, -- to be aligned with lookup_word_nb } -- Group these 3 widgets local lookup_word = OverlapGroup:new{ dimen = { w = self.content_width, h = lookup_height, }, self.lookup_word_text, lookup_edit_button, lookup_word_nb, -- last, as this might be nil } -- Different sets of buttons whether fullpage or not local buttons if self.is_wiki_fullpage then -- A save and a close button buttons = { { { id = "save", text = _("Save as EPUB"), callback = function() local InfoMessage = require("ui/widget/infomessage") local ConfirmBox = require("ui/widget/confirmbox") -- if forced_lang was specified, it may not be in our wiki_languages, -- but ReaderWikipedia will have put it in result.lang local lang = self.lang or self.wiki_languages_copy[1] -- Find a directory to save file into local dir if G_reader_settings:isTrue("wikipedia_save_in_book_dir") and not self:isDocless() then local last_file = G_reader_settings:readSetting("lastfile") if last_file then dir = last_file:match("(.*)/") end end if not dir then dir = G_reader_settings:readSetting("wikipedia_save_dir") or G_reader_settings:readSetting("home_dir") or require("apps/filemanager/filemanagerutil").getDefaultDir() end if not dir or not util.pathExists(dir) then UIManager:show(InfoMessage:new{ text = _("No folder to save article to could be found."), }) return end -- Just to be safe (none of the invalid chars, except ':' for uninteresting -- Portal: or File: wikipedia pages, should be in lookupword) local filename = self.lookupword .. "."..string.upper(lang)..".epub" filename = util.getSafeFilename(filename, dir):gsub("_", " ") local epub_path = dir .. "/" .. filename UIManager:show(ConfirmBox:new{ text = T(_("Save as %1?"), BD.filename(filename)), ok_callback = function() UIManager:scheduleIn(0.1, function() local Wikipedia = require("ui/wikipedia") Wikipedia:createEpubWithUI(epub_path, self.lookupword, lang, function(success) if success then UIManager:show(ConfirmBox:new{ text = T(_("Article saved to:\n%1\n\nWould you like to read the downloaded article now?"), BD.filepath(epub_path)), ok_callback = function() -- close all dict/wiki windows, without scheduleIn(highlight.clear()) self:onHoldClose(true) -- close current ReaderUI in 1 sec, and create a new one UIManager:scheduleIn(1.0, function() UIManager:broadcastEvent(Event:new("SetupShowReader")) if self.ui then -- close Highlight menu if any still shown if self.ui.highlight and self.ui.highlight.highlight_dialog then self.ui.highlight:onClose() end self.ui:onClose() end local ReaderUI = require("apps/reader/readerui") ReaderUI:showReader(epub_path) end) end, }) else UIManager:show(InfoMessage:new{ text = _("Saving Wikipedia article failed or interrupted."), }) end end) end) end }) end, }, { id = "close", text = _("Close"), callback = function() UIManager:close(self) end, }, }, } else local prev_dict_text = "◁◁" local next_dict_text = "▷▷" if BD.mirroredUILayout() then prev_dict_text, next_dict_text = next_dict_text, prev_dict_text end buttons = { { { id = "prev_dict", text = prev_dict_text, vsync = true, enabled = self:isPrevDictAvaiable(), callback = function() self:changeToPrevDict() end, hold_callback = function() self:changeToFirstDict() end, }, { id = "highlight", text = self:getHighlightText(), enabled = not self:isDocless() and self.highlight ~= nil, callback = function() if self:getHighlightText() == highlight_strings.highlight then self.ui:handleEvent(Event:new("Highlight")) else self.ui:handleEvent(Event:new("Unhighlight")) end -- Just update, repaint and refresh *this* button local this = self.button_table:getButtonById("highlight") if not this then return end this:enableDisable(self.highlight ~= nil) this:setText(self:getHighlightText(), this.width) this:refresh() end, }, { id = "next_dict", text = next_dict_text, vsync = true, enabled = self:isNextDictAvaiable(), callback = function() self:changeToNextDict() end, hold_callback = function() self:changeToLastDict() end, }, }, { { id = "wikipedia", -- if dictionary result, do the same search on wikipedia -- if already wiki, get the full page for the current result text_func = function() if self.is_wiki then -- @translators Full Wikipedia article. return C_("Button", "Wikipedia full") else return _("Wikipedia") end end, callback = function() UIManager:scheduleIn(0.1, function() self:lookupWikipedia(self.is_wiki) -- will get_fullpage if is_wiki end) end, }, -- Rotate thru available wikipedia languages, or Search in book if dict window { id = "search", -- if more than one language, enable it and display "current lang > next lang" -- otherwise, just display current lang text = self.is_wiki and ( #self.wiki_languages > 1 and BD.wrap(self.wiki_languages[1]).." > "..BD.wrap(self.wiki_languages[2]) or self.wiki_languages[1] ) -- (this " > " will be auro-mirrored by bidi) or _("Search"), enabled = self:canSearch(), callback = function() if self.is_wiki then self:resyncWikiLanguages(true) -- rotate & resync them UIManager:close(self) self:lookupWikipedia() else self.ui:handleEvent(Event:new("HighlightSearch")) UIManager:close(self) end end, }, { id = "close", text = _("Close"), callback = function() -- UIManager:close(self) self:onClose() end, }, }, } if not self.is_wiki and self.selected_link ~= nil then -- If highlighting some word part of a link (which should be rare), -- add a new first row with a single button to follow this link. table.insert(buttons, 1, { { id = "link", text = _("Follow Link"), callback = function() local link = self.selected_link.link or self.selected_link self.ui.link:onGotoLink(link) self:onClose() end, }, }) end end -- Bottom buttons get a bit less padding so their line separators -- reach out from the content to the borders a bit more local buttons_padding = Size.padding.default local buttons_width = inner_width - 2*buttons_padding self.button_table = ButtonTable:new{ width = buttons_width, button_font_face = "cfont", button_font_size = 20, buttons = buttons, zero_sep = true, show_parent = self, } -- Margin from screen edges local margin_top = Size.margin.default local margin_bottom = Size.margin.default if self.ui and self.ui.view and self.ui.view.footer_visible then -- We want to let the footer visible (as it can show time, battery level -- and wifi state, which might be useful when spending time reading -- definitions or wikipedia articles) margin_bottom = margin_bottom + self.ui.view.footer:getHeight() end local avail_height = Screen:getHeight() - margin_top - margin_bottom -- Region in which the window will be aligned center/top/bottom: self.region = Geom:new{ x = 0, y = margin_top, w = Screen:getWidth(), h = avail_height, } self.align = "center" local others_height = frame_bordersize * 2 -- DictQuickLookup border + title_bar:getSize().h + title_sep:getSize().h + top_to_word_span:getSize().h + lookup_word:getSize().h + word_to_definition_span:getSize().h + definition_to_bottom_span:getSize().h + self.button_table:getSize().h -- To properly adjust the definition to the height of text, we need -- the line height a ScrollTextWidget will use for the current font -- size (we'll then use this perfect height for ScrollTextWidget, -- but also for ScrollHtmlWidget, where it doesn't matter). if not self.definition_line_height then local test_widget = ScrollTextWidget:new{ text = "z", face = self.content_face, width = self.content_width, height = self.definition_height, for_measurement_only = true, -- flag it as a dummy, so it won't trigger any bogus repaint/refresh... } self.definition_line_height = test_widget:getLineHeight() test_widget:free() end if is_large_window then -- Available height for definition + components self.height = avail_height self.definition_height = self.height - others_height local nb_lines = math.floor(self.definition_height / self.definition_line_height) self.definition_height = nb_lines * self.definition_line_height local pad = self.height - others_height - self.definition_height -- put that unused height on the above span word_to_definition_span.width = word_to_definition_span.width + pad else -- Definition height was previously computed as 0.5*0.7*screen_height, so keep -- it that way. Components will add themselves to that. self.definition_height = math.floor(avail_height * 0.5 * 0.7) -- But we want it to fit to the lines that will show, to avoid -- any extra padding local nb_lines = Math.round(self.definition_height / self.definition_line_height) self.definition_height = nb_lines * self.definition_line_height self.height = self.definition_height + others_height if self.word_box then -- Try to not hide the highlighted word. We don't want to always -- get near it if we can stay center, so that more context around -- the word is still visible with the dict result. -- But if we were to be drawn over the word, move a bit if possible. local box = self.word_box -- Don't stick to the box, ensure a minimal padding between box and window local box_dict_padding = Size.padding.small local word_box_top = box.y - box_dict_padding local word_box_bottom = box.y + box.h + box_dict_padding local half_visible_height = (avail_height - self.height) / 2 if word_box_bottom > half_visible_height and word_box_top <= half_visible_height + self.height then -- word would be covered by our centered window if word_box_bottom <= avail_height - self.height then -- Window can be moved just below word self.region.y = word_box_bottom self.region.h = self.region.h - word_box_bottom self.align = "top" elseif word_box_top > self.height then -- Window can be moved just above word self.region.y = 0 self.region.h = word_box_top self.align = "bottom" end end end end -- Instantiate self.text_widget self:_instantiateScrollWidget() -- word definition self.definition_widget = FrameContainer:new{ padding = 0, padding_left = content_padding_h, padding_right = content_padding_h, margin = 0, bordersize = 0, self.text_widget, } self.dict_frame = FrameContainer:new{ radius = Size.radius.window, bordersize = frame_bordersize, padding = 0, margin = 0, background = Blitbuffer.COLOR_WHITE, VerticalGroup:new{ align = "left", title_bar, title_sep, top_to_word_span, -- word CenterContainer:new{ dimen = Geom:new{ w = inner_width, h = lookup_word:getSize().h, }, lookup_word, }, word_to_definition_span, -- definition CenterContainer:new{ dimen = Geom:new{ w = inner_width, h = self.definition_widget:getSize().h, }, self.definition_widget, }, definition_to_bottom_span, -- buttons CenterContainer:new{ dimen = Geom:new{ w = inner_width, h = self.button_table:getSize().h, }, self.button_table, } } } self.movable = MovableContainer:new{ -- We'll handle these events ourselves, and call appropriate -- MovableContainer's methods when we didn't process the event ignore_events = { -- These have effects over the definition widget, and may -- or may not be processed by it "swipe", "hold", "hold_release", "hold_pan", -- These do not have direct effect over the definition widget, -- but may happen while selecting text: we need to check -- a few things before forwarding them "touch", "pan", "pan_release", }, self.dict_frame, } self[1] = WidgetContainer:new{ align = self.align, dimen = self.region, self.movable, } UIManager:setDirty(self, function() return "partial", self.dict_frame.dimen end) end -- Whether currently DictQuickLookup is working without a document. function DictQuickLookup:isDocless() return self.ui == nil or self.ui.highlight == nil end function DictQuickLookup:getHtmlDictionaryCss() -- Using Noto Sans because Nimbus doesn't contain the IPA symbols. -- 'line-height: 1.3' to have it similar to textboxwidget, -- and follow user's choice on justification local css_justify = G_reader_settings:nilOrTrue("dict_justify") and "text-align: justify;" or "" local css = [[ @page { margin: 0; font-family: 'Noto Sans'; } body { margin: 0; line-height: 1.3; ]]..css_justify..[[ } blockquote, dd { margin: 0 1em; } ol, ul, menu { margin: 0; padding: 0 1.7em; } ]] -- For reference, MuPDF declarations with absolute units: -- "blockquote{margin:1em 40px}" -- "dd{margin:0 0 0 40px}" -- "ol,ul,menu {margin:1em 0;padding:0 0 0 30pt}" -- "hr{border-width:1px;}" -- "td,th{padding:1px}" -- -- MuPDF doesn't currently scale CSS pixels, so we have to use a font-size based measurement. -- Unfortunately MuPDF doesn't properly support `rem` either, which it bases on a hard-coded -- value of `16px`, so we have to go with `em` (or `%`). -- -- These `em`-based margins can vary slightly, but it's the best available compromise. -- -- We also keep left and right margin the same so it'll display as expected in RTL. -- Because MuPDF doesn't currently support `margin-start`, this results in a slightly -- unconventional but hopefully barely noticeable right margin for