--[[-- Displays some text in a scrollable view. @usage local textviewer = TextViewer:new{ title = _("I can scroll!"), text = _("I'll need to be longer than this example to scroll."), } UIManager:show(textviewer) ]] local BD = require("ui/bidi") local Blitbuffer = require("ffi/blitbuffer") local ButtonDialog = require("ui/widget/buttondialog") local ButtonTable = require("ui/widget/buttontable") local CenterContainer = require("ui/widget/container/centercontainer") local CheckButton = require("ui/widget/checkbutton") local Device = require("device") local Geom = require("ui/geometry") local Font = require("ui/font") local FrameContainer = require("ui/widget/container/framecontainer") local GestureRange = require("ui/gesturerange") local InputContainer = require("ui/widget/container/inputcontainer") local InputDialog = require("ui/widget/inputdialog") local MovableContainer = require("ui/widget/container/movablecontainer") local Notification = require("ui/widget/notification") local ScrollTextWidget = require("ui/widget/scrolltextwidget") local Size = require("ui/size") local TextBoxWidget = require("ui/widget/textboxwidget") local TitleBar = require("ui/widget/titlebar") local UIManager = require("ui/uimanager") local VerticalGroup = require("ui/widget/verticalgroup") local WidgetContainer = require("ui/widget/container/widgetcontainer") local T = require("ffi/util").template local lfs = require("libs/libkoreader-lfs") local util = require("util") local _ = require("gettext") local Screen = Device.screen local TextViewer = InputContainer:extend{ title = nil, text = nil, charlist = nil, -- internal width = nil, height = nil, buttons_table = nil, -- See TextBoxWidget for details about these options -- We default to justified and auto_para_direction to adapt -- to any kind of text we are given (book descriptions, -- bookmarks' text, translation results...). -- When used to display more technical text (HTML, CSS, -- application logs...), it's best to reset them to false. alignment = "left", lang = nil, para_direction_rtl = nil, auto_para_direction = true, alignment_strict = false, title_face = nil, -- use default from TitleBar title_multilines = nil, -- see TitleBar for details title_shrink_font_to_fit = nil, -- see TitleBar for details show_menu = true, -- titlebar left icon monospace_font = nil, -- internal text_font_size = nil, -- internal justified = nil, -- internal fgcolor = Blitbuffer.COLOR_BLACK, text_padding = Size.padding.large, text_margin = Size.margin.small, button_padding = Size.padding.default, -- Bottom row with Close, Find buttons. Also added when no caller's buttons defined. add_default_buttons = nil, default_hold_callback = nil, -- on each default button find_centered_lines_count = 5, -- line with find results to be not far from the center text_type = "general", text_types = { general = { monospace_font = false, font_size = 20, justified = false }, file_content = { monospace_font = false, font_size = 20, justified = false }, book_info = { monospace_font = false, font_size = 20, justified = true }, bookmark = { monospace_font = false, font_size = 20, justified = true }, lookup = { monospace_font = false, font_size = 20, justified = false }, code = { monospace_font = true, font_size = 16, justified = false }, }, } function TextViewer:init(reinit) local screen_w = Screen:getWidth() local screen_h = Screen:getHeight() self.align = "center" self.region = Geom:new{ w = screen_w, h = screen_h, } self.width = self.width or screen_w - Screen:scaleBySize(30) self.height = self.height or screen_h - Screen:scaleBySize(30) if not reinit then local text_types = G_reader_settings:readSetting("textviewer_text_types") local text_settings = text_types and text_types[self.text_type] or self.text_types[self.text_type] self.monospace_font = text_settings.monospace_font self.text_font_size = text_settings.font_size self.justified = text_settings.justified end local text_font_face = self.monospace_font and "smallinfont" or "x_smallinfofont" self._find_next = false self._find_next_button = false self._old_virtual_line_num = 1 if Device:hasKeys() then self.key_events.Close = { { Device.input.group.Back } } end if Device:isTouchDevice() then local range = Geom:new{ w = screen_w, h = screen_h, } self.ges_events = { TapClose = { GestureRange:new{ ges = "tap", range = range, }, }, Swipe = { GestureRange:new{ ges = "swipe", range = range, }, }, MultiSwipe = { GestureRange:new{ ges = "multiswipe", range = range, }, }, -- 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, start_idx, end_idx, to_source_index_func) self:handleTextSelection(text, hold_duration, start_idx, end_idx, to_source_index_func) 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 self.titlebar = TitleBar:new{ width = self.width, align = "left", with_bottom_line = true, title = self.title, title_face = self.title_face, title_multilines = self.title_multilines, title_shrink_font_to_fit = self.title_shrink_font_to_fit, left_icon = self.show_menu and "appbar.menu", left_icon_tap_callback = function() self:showMenu() end, close_callback = function() self:onClose() end, show_parent = self, } -- Callback to enable/disable buttons, for at-top/at-bottom feedback local prev_at_top = false -- Buttons were created enabled local prev_at_bottom = false local function button_update(id, enable) local button = self.button_table:getButtonById(id) if button then if enable then button:enable() else button:disable() end button:refresh() end end self._buttons_scroll_callback = function(low, high) if prev_at_top and low > 0 then button_update("top", true) prev_at_top = false elseif not prev_at_top and low <= 0 then button_update("top", false) prev_at_top = true end if prev_at_bottom and high < 1 then button_update("bottom", true) prev_at_bottom = false elseif not prev_at_bottom and high >= 1 then button_update("bottom", false) prev_at_bottom = true end end -- buttons local default_buttons = { { text = _("Find"), id = "find", callback = function() if self._find_next then self:findCallback() else self:findDialog() end end, hold_callback = function() if self._find_next then self:findDialog() else if self.default_hold_callback then self.default_hold_callback() end end end, }, { text = "⇱", id = "top", callback = function() self.scroll_text_w:scrollToTop() end, hold_callback = self.default_hold_callback, allow_hold_when_disabled = true, }, { text = "⇲", id = "bottom", callback = function() self.scroll_text_w:scrollToBottom() end, hold_callback = self.default_hold_callback, allow_hold_when_disabled = true, }, { text = _("Close"), callback = function() self:onClose() end, hold_callback = self.default_hold_callback, }, } local buttons = self.buttons_table or {} if (self.add_default_buttons and not reinit) or not self.buttons_table then table.insert(buttons, default_buttons) end self.button_table = ButtonTable:new{ width = self.width - 2*self.button_padding, buttons = buttons, zero_sep = true, show_parent = self, } local textw_height = self.height - self.titlebar:getHeight() - self.button_table:getSize().h self.scroll_text_w = ScrollTextWidget:new{ text = self.text, face = Font:getFace(text_font_face, self.text_font_size), fgcolor = self.fgcolor, width = self.width - 2*self.text_padding - 2*self.text_margin, height = textw_height - 2*self.text_padding -2*self.text_margin, dialog = self, alignment = self.alignment, justified = self.justified, lang = self.lang, para_direction_rtl = self.para_direction_rtl, auto_para_direction = self.auto_para_direction, alignment_strict = self.alignment_strict, scroll_callback = self._buttons_scroll_callback, } self.textw = FrameContainer:new{ padding = self.text_padding, margin = self.text_margin, bordersize = 0, self.scroll_text_w } self.frame = FrameContainer:new{ radius = Size.radius.window, padding = 0, margin = 0, background = Blitbuffer.COLOR_WHITE, VerticalGroup:new{ self.titlebar, CenterContainer:new{ dimen = Geom:new{ w = self.width, h = self.textw:getSize().h, }, self.textw, }, CenterContainer:new{ dimen = Geom:new{ w = self.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 text widget, and may -- or may not be processed by it "swipe", "hold", "hold_release", "hold_pan", -- These do not have direct effect over the text widget, -- but may happen while selecting text: we need to check -- a few things before forwarding them "touch", "pan", "pan_release", }, self.frame, } self[1] = WidgetContainer:new{ align = self.align, dimen = self.region, self.movable, } end function TextViewer:onCloseWidget() UIManager:setDirty(nil, function() return "partial", self.frame.dimen end) end function TextViewer:onShow() UIManager:setDirty(self, function() return "partial", self.frame.dimen end) return true end function TextViewer:onTapClose(arg, ges_ev) if ges_ev.pos:notIntersectWith(self.frame.dimen) then self:onClose() end return true end function TextViewer: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 TextViewer:onClose() UIManager:close(self) if self.close_callback then self.close_callback() end return true end function TextViewer:onSwipe(arg, ges) if ges.pos:intersectWith(self.textw.dimen) then local direction = BD.flipDirectionIfMirroredUILayout(ges.direction) if direction == "west" then self.scroll_text_w:scrollText(1) return true elseif direction == "east" then self.scroll_text_w:scrollText(-1) return true else -- trigger a full-screen HQ flashing 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 -- Let our MovableContainer handle swipe outside of text return self.movable:onMovableSwipe(arg, ges) end -- The following handlers are similar to the ones in DictQuickLookup: -- we just forward to our MoveableContainer the events that our -- TextBoxWidget has not handled with text selection. function TextViewer:onHoldStartText(_, ges) -- Forward Hold events not processed by TextBoxWidget event handler -- to our MovableContainer return self.movable:onMovableHold(_, ges) end function TextViewer:onHoldPanText(_, ges) -- Forward Hold events not processed by TextBoxWidget event handler -- to our MovableContainer -- We only forward it if we did forward the Touch if self.movable._touch_pre_pan_was_inside then return self.movable:onMovableHoldPan(arg, ges) end end function TextViewer:onHoldReleaseText(_, ges) -- Forward Hold events not processed by TextBoxWidget event handler -- to our MovableContainer return self.movable:onMovableHoldRelease(_, ges) end -- These 3 event processors are just used to forward these events -- to our MovableContainer, under certain conditions, to avoid -- unwanted moves of the window while we are selecting text in -- the definition widget. function TextViewer:onForwardingTouch(arg, ges) -- This Touch may be used as the Hold we don't get (for example, -- when we start our Hold on the bottom buttons) if not ges.pos:intersectWith(self.textw.dimen) then return self.movable:onMovableTouch(arg, ges) else -- Ensure this is unset, so we can use it to not forward HoldPan self.movable._touch_pre_pan_was_inside = false end end function TextViewer:onForwardingPan(arg, ges) -- We only forward it if we did forward the Touch or are currently moving if self.movable._touch_pre_pan_was_inside or self.movable._moving then return self.movable:onMovablePan(arg, ges) end end function TextViewer:onForwardingPanRelease(arg, ges) -- Allow scrolling with the mousewheel if ges.from_mousewheel and ges.relative and ges.relative.y then if ges.relative.y < 0 then self.scroll_text_w:scrollText(1) elseif ges.relative.y > 0 then self.scroll_text_w:scrollText(-1) end return true end -- We can forward onMovablePanRelease() does enough checks return self.movable:onMovablePanRelease(arg, ges) end function TextViewer:findDialog() local input_dialog, check_button_case input_dialog = InputDialog:new{ title = _("Enter text to search for"), input = self.search_value, buttons = { { { text = _("Cancel"), id = "close", callback = function() UIManager:close(input_dialog) end, }, { text = _("Find first"), callback = function() self._find_next = false self:findCallback(input_dialog) end, }, { text = _("Find next"), is_enter_default = true, callback = function() self._find_next = true self:findCallback(input_dialog) end, }, }, }, } check_button_case = CheckButton:new{ text = _("Case sensitive"), checked = self.case_sensitive, parent = input_dialog, callback = function() self.case_sensitive = check_button_case.checked end, } input_dialog:addWidget(check_button_case) UIManager:show(input_dialog) input_dialog:onShowKeyboard(true) end function TextViewer:findCallback(input_dialog) if input_dialog then self.search_value = input_dialog:getInputText() if self.search_value == "" then return end UIManager:close(input_dialog) end local start_pos = 1 if self._find_next then local charpos, new_virtual_line_num = self.scroll_text_w:getCharPos() if math.abs(new_virtual_line_num - self._old_virtual_line_num) > self.find_centered_lines_count then start_pos = self.scroll_text_w:getCharPosAtXY(0, 0) -- first char of the top line else start_pos = (charpos or 0) + 1 -- previous search result end end local char_pos, search_charlist char_pos, self.charlist, search_charlist = util.stringSearch(self.charlist or self.text, self.search_value, self.case_sensitive, start_pos) local msg if char_pos > 0 then self:setTextBold(char_pos, #search_charlist) self.scroll_text_w:moveCursorToCharPos(char_pos, self.find_centered_lines_count) msg = T(_("Found, screen line %1."), self.scroll_text_w:getCharPosLineNum()) self._find_next = true self._old_virtual_line_num = select(2, self.scroll_text_w:getCharPos()) else msg = _("Not found.") self._find_next = false self._old_virtual_line_num = 1 end UIManager:show(Notification:new{ text = msg, }) if self._find_next_button ~= self._find_next then self._find_next_button = self._find_next local button_text = self._find_next and _("Find next") or _("Find") local find_button = self.button_table:getButtonById("find") find_button:setText(button_text, find_button.width) find_button:refresh() end end function TextViewer:handleTextSelection(text, hold_duration, start_idx, end_idx, to_source_index_func) if self.text_selection_callback then self.text_selection_callback(text, hold_duration, start_idx, end_idx, to_source_index_func) return end if Device:hasClipboard() then Device.input.setClipboardText(text) UIManager:show(Notification:new{ text = start_idx == end_idx and _("Word copied to clipboard.") or _("Selection copied to clipboard."), }) end end function TextViewer:reinit() local text_settings = G_reader_settings:readSetting("textviewer_text_types", {}) text_settings[self.text_type] = { monospace_font = self.monospace_font, font_size = self.text_font_size, justified = self.justified } local low, high = self.scroll_text_w.text_widget:getVisibleHeightRatios() -- try to keep position local ratio = low == 0 and 0 or (low + high) / 2 -- if we are at the beginning, keep the first line visible self:init(true) -- do not add default buttons once more UIManager:setDirty("all", "partial", self.frame.dimen) self.scroll_text_w:scrollToRatio(ratio, ratio == 0) end function TextViewer:setTextBold(start_pos, len) local end_pos = start_pos + len local text = self.text if self.charlist == nil then self.charlist = util.splitToChars(text) end local charlist = { TextBoxWidget.PTF_HEADER } for i, char in ipairs(self.charlist) do if i == start_pos then table.insert(charlist, TextBoxWidget.PTF_BOLD_START) elseif i == end_pos then table.insert(charlist, TextBoxWidget.PTF_BOLD_END) end table.insert(charlist, char) end self.text = table.concat(charlist) self:reinit() self.text = text -- restore original text end function TextViewer:showMenu() local dialog local buttons = { {{ text_func = function() return T(_("Font size: %1"), self.text_font_size) end, align = "left", callback = function() UIManager:close(dialog) local SpinWidget = require("ui/widget/spinwidget") local widget = SpinWidget:new{ title_text = _("Font size"), value = self.text_font_size, value_min = 12, value_max = 30, default_value = self.monospace_font and 16 or 20, keep_shown_on_apply = true, callback = function(spin) self.text_font_size = spin.value self:reinit() end, } UIManager:show(widget) end, }}, {{ text = _("Monospace font"), checked_func = function() return self.monospace_font end, align = "left", callback = function() self.monospace_font = not self.monospace_font self:reinit() end, }}, {{ text = _("Justify"), checked_func = function() return self.justified end, align = "left", callback = function() self.justified = not self.justified self:reinit() end, }}, } dialog = ButtonDialog:new{ shrink_unneeded_width = true, buttons = buttons, anchor = function() return self.titlebar.left_button.image.dimen end, } UIManager:show(dialog) end -- Register DocumentRegistry auxiliary provider. function TextViewer:register(registry) registry:addAuxProvider({ provider_name = _("Text viewer"), provider = "textviewer", order = 20, -- order in OpenWith dialog enabled_func = function() return true -- all files end, callback = TextViewer.openFile, disable_file = true, disable_type = false, }) end function TextViewer.openFile(file) local function _openFile(file_path) local file_handle = io.open(file_path, "rb") if not file_handle then return end local file_content = file_handle:read("*all") file_handle:close() UIManager:show(TextViewer:new{ title = file_path, title_multilines = true, text = file_content, text_type = "file_content", }) end local attr = lfs.attributes(file) if attr then if attr.size > 400000 then local ConfirmBox = require("ui/widget/confirmbox") UIManager:show(ConfirmBox:new{ text = T(_("This file is %2:\n\n%1\n\nAre you sure you want to open it?\n\nOpening big files may take some time."), BD.filepath(file), util.getFriendlySize(attr.size)), ok_text = _("Open"), ok_callback = function() _openFile(file) end, }) else _openFile(file) end end end return TextViewer