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