From 546cb14ccc7a965854bfa6450cb7f9e58e89dc76 Mon Sep 17 00:00:00 2001 From: David <97603719+Commodore64user@users.noreply.github.com> Date: Mon, 17 Feb 2025 20:41:34 +0000 Subject: [PATCH] [DictQuickLookup] NT: add text selection to the dictionary widget (#13232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initially, when you open the dictionary widget from the reader, FocusManager will function as usual. On Kindle 4 or devices with a keyboard, I have assigned the shortcuts screenkb + up/down or shift + up/down to initiate text selection (as a remainder, on kindle text selection in reader is initiated with up/down so it is fairly similar). At this point, FocusManager is disabled, allowing the cursor keys (and press) to control the now-visible crosshairs. Pressing back should stop text selection and restore FocusManager’s control of the widget. --- .../apps/reader/modules/readerhighlight.lua | 39 ++- frontend/ui/widget/dictquicklookup.lua | 310 ++++++++++++++++-- frontend/ui/widget/focusmanager.lua | 9 + 3 files changed, 327 insertions(+), 31 deletions(-) diff --git a/frontend/apps/reader/modules/readerhighlight.lua b/frontend/apps/reader/modules/readerhighlight.lua index 51f1595c6..d137b9c2a 100644 --- a/frontend/apps/reader/modules/readerhighlight.lua +++ b/frontend/apps/reader/modules/readerhighlight.lua @@ -3,6 +3,7 @@ local BlitBuffer = require("ffi/blitbuffer") local ButtonDialog = require("ui/widget/buttondialog") local ConfirmBox = require("ui/widget/confirmbox") local Device = require("device") +local DoubleSpinWidget = require("ui/widget/doublespinwidget") local Event = require("ui/event") local Geom = require("ui/geometry") local InfoMessage = require("ui/widget/infomessage") @@ -798,25 +799,39 @@ Except when in two columns mode, where this is limited to showing only the previ if not Device:isTouchDevice() and Device:hasDPad() then table.insert(menu_items.long_press.sub_item_table, { text_func = function() - return T(_("Crosshairs speed for text selection: %1"), G_reader_settings:readSetting("highlight_non_touch_factor") or 4) + local reader_speed = G_reader_settings:readSetting("highlight_non_touch_factor") or 4 + local dict_speed = G_reader_settings:readSetting("highlight_non_touch_factor_dict") or 3 + return T(_("Crosshairs speed (reader/dict): %1 / %2"), reader_speed, dict_speed) end, callback = function(touchmenu_instance) - local curr_val = G_reader_settings:readSetting("highlight_non_touch_factor") or 4 - local spin_widget = SpinWidget:new{ - value = curr_val, - value_min = 0.25, - value_max = 5, - precision = "%.2f", - value_step = 0.25, - default_value = 4, + local reader_speed = G_reader_settings:readSetting("highlight_non_touch_factor") or 4 + local dict_speed = G_reader_settings:readSetting("highlight_non_touch_factor_dict") or 3 + local double_spin_widget = DoubleSpinWidget:new{ + left_text = _("Reader"), + left_value = reader_speed, + left_min = 0.25, + left_max = 5, + left_default = 4, + left_precision = "%.2f", + left_step = 0.25, + left_hold_step = 0.05, + right_text = _("Dictionary"), + right_value = dict_speed, + right_min = 0.25, + right_max = 5, + right_default = 3, + right_precision = "%.2f", + right_step = 0.25, + right_hold_step = 0.05, title_text = _("Crosshairs speed"), info_text = _("Select a decimal value from 0.25 to 5. A smaller value increases the travel distance of the crosshairs per keystroke. Font size and this value are inversely correlated, meaning a smaller font size requires a larger value and vice versa."), - callback = function(spin) - G_reader_settings:saveSetting("highlight_non_touch_factor", spin.value) + callback = function(left_value, right_value) + G_reader_settings:saveSetting("highlight_non_touch_factor", left_value) + G_reader_settings:saveSetting("highlight_non_touch_factor_dict", right_value) if touchmenu_instance then touchmenu_instance:updateItems() end end } - UIManager:show(spin_widget) + UIManager:show(double_spin_widget) end, }) table.insert(menu_items.long_press.sub_item_table, { diff --git a/frontend/ui/widget/dictquicklookup.lua b/frontend/ui/widget/dictquicklookup.lua index 06c1164d1..9bf5afc8c 100644 --- a/frontend/ui/widget/dictquicklookup.lua +++ b/frontend/ui/widget/dictquicklookup.lua @@ -53,6 +53,7 @@ local DictQuickLookup = InputContainer:extend{ dict_index = 1, width = nil, height = nil, + nt_text_selector_indicator = nil, -- crosshairs for text selection on non-touch devices -- sboxes containing highlighted text, quick lookup window tries to not hide the word word_boxes = nil, @@ -103,25 +104,12 @@ function DictQuickLookup:init() 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 } } - self.key_events.ReadNextResult = { { Input.group.PgFwd } } - self.key_events.Close = { { Input.group.Back } } - self.key_events.MenuKeyPress = { { "Menu" } } - if Device:hasKeyboard() then - self.key_events.ChangeToPrevDict = { { "Shift", "Left" } } - self.key_events.ChangeToNextDict = { { "Shift", "Right" } } - self.key_events.LookupInputWordClear = { { Input.group.Alphabet }, event = "LookupInputWord" } - -- We need to concat here so that the 'del' event press, which propagates to inputText (desirable for previous key_event, - -- i.e., LookupInputWordClear) does not remove the last char of self.word - self.key_events.LookupInputWord = { { Device:hasSymKey() and "Del" or "Backspace" }, args = self.word .." " } - elseif Device:hasScreenKB() then - self.key_events.ChangeToPrevDict = { { "ScreenKB", "Left" } } - self.key_events.ChangeToNextDict = { { "ScreenKB", "Right" } } - -- same case as hasKeyboard - self.key_events.LookupInputWord = { { "ScreenKB", "Back" }, args = self.word .." " } - end + self.allow_key_text_selection = Device:hasDPad() + if self.allow_key_text_selection then + self.text_selection_started = false + self.previous_indicator_pos = nil end + self:registerKeyEvents() if Device:isTouchDevice() then local range = Geom:new{ x = 0, y = 0, @@ -537,6 +525,17 @@ function DictQuickLookup:init() }, }, } + if self.allow_key_text_selection and Device:hasFewKeys() then + table.insert(buttons, 1, { + { + id = "text_selection", + text = _("Text selection"), + callback = function() + self:onStartTextSelectorIndicator() + end, + } + }) + 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. @@ -748,7 +747,7 @@ function DictQuickLookup:init() -- NT: add dict_title.left_button and lookup_edit_button to FocusManager. -- It is better to add these two buttons into self.movable, but it is not a FocusManager. -- Only self.button_table is a FocusManager, so the workaround is inserting these two buttons into self.button_table.layout. - if Device:hasDPad() then + if Device:hasDPad() and not (Device:hasScreenKB() or Device:hasKeyboard()) then table.insert(self.button_table.layout, 1, { self.dict_title.left_button }) table.insert(self.button_table.layout, 2, { lookup_edit_button }) -- Refocus on the updated layout @@ -759,6 +758,42 @@ function DictQuickLookup:init() table.insert(DictQuickLookup.window_list, self) end +function DictQuickLookup:registerKeyEvents() + if Device:hasKeys() then + self.key_events.ReadPrevResult = { { Input.group.PgBack } } + self.key_events.ReadNextResult = { { Input.group.PgFwd } } + self.key_events.Close = { { Input.group.Back } } + self.key_events.MenuKeyPress = { { "Menu" } } + if Device:hasScreenKB() or Device:hasKeyboard() then + local modifier = Device:hasScreenKB() and "ScreenKB" or "Shift" + self.key_events.ChangeToPrevDict = { { modifier, Input.group.PgBack } } + self.key_events.ChangeToNextDict = { { modifier, Input.group.PgFwd } } + self.key_events.StartOrUpTextSelectorIndicator = { { modifier, "Up" }, event = "StartOrMoveTextSelectorIndicator", args = { 0, -1, true } } + self.key_events.StartOrDownTextSelectorIndicator = { { modifier, "Down" }, event = "StartOrMoveTextSelectorIndicator", args = { 0, 1, true } } + self.key_events.FastLeftTextSelectorIndicator = { { modifier, "Left" }, event = "MoveTextSelectorIndicator", args = { -1, 0, true } } + self.key_events.FastRightTextSelectorIndicator = { { modifier, "Right" }, event = "MoveTextSelectorIndicator", args = { 1, 0, true } } + if Device:hasKeyboard() then + self.key_events.LookupInputWordClear = { { Input.group.Alphabet }, event = "LookupInputWord" } + -- We need to concat here so that the 'del' event press, which propagates to inputText (desirable for previous key_event, + -- i.e., LookupInputWordClear) does not remove the last char of self.word + self.key_events.LookupInputWord = { { Device:hasSymKey() and "Del" or "Backspace" }, args = self.word .." " } + else + -- same case as hasKeyboard + self.key_events.LookupInputWord = { { "ScreenKB", "Back" }, args = self.word .." " } + end + end + if Device:hasDPad() then + self.key_events.TextSelectorPress = { { "Press" } } + self.key_events.UpTextSelectorIndicator = { { "Up" }, event = "MoveTextSelectorIndicator", args = { 0, -1 } } + self.key_events.DownTextSelectorIndicator = { { "Down" }, event = "MoveTextSelectorIndicator", args = { 0, 1 } } + self.key_events.RightTextSelectorIndicator = { { "Right" }, event = "MoveTextSelectorIndicator", args = { 1, 0 } } + if not Device:hasFewKeys() then + self.key_events.LeftTextSelectorIndicator = { { "Left" }, event = "MoveTextSelectorIndicator", args = { -1, 0 } } + end + end + end +end + -- Whether currently DictQuickLookup is working without a document. function DictQuickLookup:isDocless() return self.ui == nil or self.ui.highlight == nil @@ -832,6 +867,18 @@ function DictQuickLookup:_instantiateScrollWidget() html_link_tapped_callback = function(link) self.html_dictionary_link_tapped_callback(self.dictionary, link) end, + -- We need to override the widget's paintTo method to draw our indicator + paintTo = self.allow_key_text_selection and function(widget, bb, x, y) + -- Call original paintTo from ScrollHtmlWidget + ScrollHtmlWidget.paintTo(widget, bb, x, y) + -- Draw our indicator on top if we have one + if self.nt_text_selector_indicator then + local rect = self.nt_text_selector_indicator + -- Draw indicator - use crosshairs style + bb:paintRect(rect.x + x, rect.y + y + rect.h/2 - 1, rect.w, 2, Blitbuffer.COLOR_BLACK) + bb:paintRect(rect.x + x + rect.w/2 - 1, rect.y + y, 2, rect.h, Blitbuffer.COLOR_BLACK) + end + end or nil, } self.text_widget = self.shw_widget else @@ -848,6 +895,18 @@ function DictQuickLookup:_instantiateScrollWidget() image_alt_face = self.image_alt_face, images = self.images, highlight_text_selection = true, + -- We need to override the widget's paintTo method to draw our indicator + paintTo = self.allow_key_text_selection and function(widget, bb, x, y) + -- Call original paintTo from ScrollTextWidget + ScrollTextWidget.paintTo(widget, bb, x, y) + -- Draw our indicator on top if we have one + if self.nt_text_selector_indicator then + local rect = self.nt_text_selector_indicator + -- Draw indicator - use crosshairs style + bb:paintRect(rect.x + x, rect.y + y + rect.h/2 - 1, rect.w, 2, Blitbuffer.COLOR_BLACK) + bb:paintRect(rect.x + x + rect.w/2 - 1, rect.y + y, 2, rect.h, Blitbuffer.COLOR_BLACK) + end + end or nil, } self.text_widget = self.stw_widget end @@ -1155,6 +1214,11 @@ function DictQuickLookup:onTap(arg, ges_ev) end function DictQuickLookup:onClose(no_clear) + if self.allow_key_text_selection and self.nt_text_selector_indicator then + -- If we're in text selection mode, stop it + self:onStopTextSelectorIndicator(true) + return true + end for menu, _ in pairs(self.menu_opened) do UIManager:close(menu) end @@ -1622,4 +1686,212 @@ function DictQuickLookup:clearDictionaryHighlight() end end +--[[ +This function initializes and displays a text selection indicator in the dictionary quick lookup widget. + 1. Suspends focus management and key events in the button table during text selection + 2. Saves and clears the current focus position (FocusManager) + 3. Creates the indicator and updates the UI to show it on-screen +@return boolean Returns true if the indicator was successfully started, false otherwise +]] +function DictQuickLookup:onStartTextSelectorIndicator() + if not self.definition_widget then return false end -- not yet set up + if self.nt_text_selector_indicator then return false end -- already started + -- Suspend focus management from button_table instance to prevent the d-pad + -- and press keys from moving focus during text selection. + self.button_table.movement_allowed = { x = false, y = false } + -- Also, temporarily disable key_events during text selection. + self.button_table.key_events_enabled = false + -- Save current focused-item position before un-focusing it. + self._save_focused_item = nil + if self.button_table:getFocusItem() then + self._save_focused_item = { + x = self.button_table.selected.x, + y = self.button_table.selected.y + } + -- it's complicated, but we need two rounds of refocusing in order to clear up the existing focus + local FocusManager = require("ui/widget/focusmanager") + self.button_table:moveFocusTo(1, 1) + self.button_table:moveFocusTo(1, 1, FocusManager.NOT_FOCUS) + end + -- Create rect with coordinates relative to the content area + local rect = self._previous_indicator_pos + if not rect then + rect = Geom:new() + rect.x = math.floor((self.content_width - rect.w) / 2) + rect.y = math.floor((self.definition_height - rect.h) / 2) + rect.w = Size.item.height_default + rect.h = rect.w + end + self.nt_text_selector_indicator = rect + -- Mark the entire definition widget area as dirty to ensure the indicator is drawn + UIManager:setDirty(self, function() return "ui", self.definition_widget.dimen end) + return true +end + +--[[ +Stops the text selector indicator and restores normal UI behavior. +@param need_clear_selection boolean Whether to clear dictionary highlights after stopping selector +@return boolean Returns true if indicator was stopped, false if no indicator existed +]] +function DictQuickLookup:onStopTextSelectorIndicator(need_clear_selection) + if not self.nt_text_selector_indicator then return false end + -- resume focus manager's normal operation + self.button_table.movement_allowed = { x = true, y = true } + -- and re-enable key_events + self.button_table.key_events_enabled = true + -- Restore previous focus if it was saved + if self._save_focused_item then + self.button_table:moveFocusTo(self._save_focused_item.x, self._save_focused_item.y) + self._save_focused_item = nil + end + local rect = self.nt_text_selector_indicator + self._previous_indicator_pos = rect + self._text_selection_started = false + self.nt_text_selector_indicator = nil + if self._hold_duration then self._hold_duration = nil end + -- Mark definition widget area as dirty for clean re-draw + UIManager:setDirty(self, function() return "ui", self.definition_widget.dimen end) + if need_clear_selection then self:clearDictionaryHighlight() end + return true +end + +--[[ +This function controls the positioning and movement of the text selection indicator, +including both normal and quick movement modes. It ensures the indicator stays within +the boundaries of the content area and updates the display accordingly. +@param args {table} Array containing movement parameters: + - dx {number} Horizontal movement delta + - dy {number} Vertical movement delta + - quick_move {boolean} Whether to use quick movement mode +@return {boolean} Returns true if movement was handled, false if text widget or + indicator is not available +]] +function DictQuickLookup:onMoveTextSelectorIndicator(args) + if not (self.text_widget and self.nt_text_selector_indicator) then return false end + local dx, dy, quick_move = unpack(args) + local move_distance = Size.item.height_default / (G_reader_settings:readSetting("highlight_non_touch_factor_dict") or 3) + local rect = self.nt_text_selector_indicator:copy() + local quick_move_distance_dx = self.content_width * (1/4) + local quick_move_distance_dy = self.definition_height * (1/4) + if quick_move then + rect.x = rect.x + quick_move_distance_dx * dx + rect.y = rect.y + quick_move_distance_dy * dy + else + rect.x = rect.x + move_distance * dx + rect.y = rect.y + move_distance * dy + end + -- Ensure the indicator stays within the content area. + if rect.x < 0 then rect.x = 0 end + if rect.x + rect.w > self.content_width then + if Device:hasFewKeys() then + rect.x = 0 -- wrap around to beginning when reaching end + else + rect.x = self.content_width - rect.w + end + end + if rect.y < 0 then rect.y = 0 end + if rect.y + rect.h > self.definition_height then + rect.y = self.definition_height - rect.h + end + -- Update widget state + self.nt_text_selector_indicator = rect + if self._text_selection_started then + local selection_widget = self:_getSelectionWidget(self) + if selection_widget then + selection_widget:onHoldPanText(nil, self:_createTextSelectionGesture("hold_pan")) + end + end + -- mark widget dirty to ensure the paintTo method that draws the crosshairs is called + UIManager:setDirty(self, function() return "ui", self.definition_widget.dimen end) + return true +end + +--[[ +@details This function manages the text selection process and subsequent actions: + - Initiates text selection on first press + - On second press (when selection is complete): + * Processes the selection + * Handles Wikipedia/Dictionary lookup +]] +function DictQuickLookup:onTextSelectorPress() + if not self.nt_text_selector_indicator then return false end + local selection_widget = self:_getSelectionWidget(self) + if not selection_widget then self:onStopTextSelectorIndicator() return end + if not self._text_selection_started then + -- start text selection on first press + self._text_selection_started = true + -- we'll time the hold duration to allow switching from wiki to dict + self._hold_duration = time.now() -- on your marks, get set, go! + selection_widget:onHoldStartText(nil, self:_createTextSelectionGesture("hold")) + -- center indicator on selected text if available + if selection_widget.highlight_rects and #selection_widget.highlight_rects > 0 then + local highlight = selection_widget.highlight_rects[1] + local indicator = self.nt_text_selector_indicator + indicator.x = highlight.x + (highlight.w/2) - (indicator.w/2) + indicator.y = highlight.y + (highlight.h/2) - (indicator.h/2) + UIManager:setDirty(self, function() return "ui", self.definition_widget.dimen end) + end + return true + end + -- second press, + -- process the hold release event which finalizes text selection + selection_widget:onHoldReleaseText(nil, self:_createTextSelectionGesture("hold_release")) + local hold_duration = time.to_s(time.since(self._hold_duration)) + local selected_text + -- both text_widget and htmlbox_widget handle text parsing a bit differently, ¯\_(ツ)_/¯ + if self.is_html then + -- For HtmlBoxWidget, highlight_text should contain the complete text selection. + selected_text = selection_widget.highlight_text + else + -- For TextBoxWidget, extract the selected text using the indices. + selected_text = selection_widget.text:sub( + selection_widget.highlight_start_idx, + selection_widget.highlight_end_idx + ) + end + if selected_text then + local lookup_wikipedia = self.is_wiki + if lookup_wikipedia and hold_duration > 5 then + -- allow switching domain with a long hold (> 5 secs) + lookup_wikipedia = false + end + local new_dict_close_callback = function() self:clearDictionaryHighlight() end + if lookup_wikipedia then + self:lookupWikipedia(false, selected_text, nil, nil, new_dict_close_callback) + else + self.ui:handleEvent(Event:new("LookupWord", selected_text, nil, nil, nil, nil, new_dict_close_callback)) + end + end + self:onStopTextSelectorIndicator() + return true +end + +function DictQuickLookup:onStartOrMoveTextSelectorIndicator(args) + if not self.nt_text_selector_indicator then + self:onStartTextSelectorIndicator() + else + self:onMoveTextSelectorIndicator(args) + end + return true +end + +-- helper function to get the actual widget that handles text selection +function DictQuickLookup:_getSelectionWidget(instance) + return instance.is_html and instance.text_widget.htmlbox_widget or instance.text_widget.text_widget +end + +function DictQuickLookup:_createTextSelectionGesture(gesture) + local point = self.nt_text_selector_indicator:copy() + -- Add the definition_widget's absolute position to get correct screen coordinates + point.x = point.x + point.w / 2 + self.definition_widget.dimen.x + point.y = point.y + point.h / 2 + self.definition_widget.dimen.y + point.w = 0 + point.h = 0 + return { + ges = gesture, + pos = point, + time = time.realtime(), + } +end + return DictQuickLookup diff --git a/frontend/ui/widget/focusmanager.lua b/frontend/ui/widget/focusmanager.lua index e4fb9e0fa..9460ef367 100644 --- a/frontend/ui/widget/focusmanager.lua +++ b/frontend/ui/widget/focusmanager.lua @@ -30,6 +30,7 @@ local FocusManager = InputContainer:extend{ selected = nil, -- defaults to x=1, y=1 layout = nil, -- mandatory movement_allowed = { x = true, y = true }, + key_events_enabled = true, } -- Only build the default mappings once on initialization, or when an external keyboard is (dis-)/connected. @@ -517,4 +518,12 @@ function FocusManager:refocusWidget(nextTick, focus_flags) end end +function FocusManager:onKeyPress(key) + -- Add check for key_events_enabled + if not self.key_events_enabled then + return false + end + return InputContainer.onKeyPress(self, key) +end + return FocusManager