[DictQuickLookup] NT: add text selection to the dictionary widget (#13232)
Some checks failed
macos / macOS 13 x86-64 🔨15.2 🎯10.15 (push) Has been cancelled
macos / macOS 14 ARM64 🔨15.4 🎯11.0 (push) Has been cancelled

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 <kbd>screenkb</kbd> + <kbd>up</kbd>/<kbd>down</kbd>   or <kbd>shift</kbd> + <kbd>up</kbd>/<kbd>down</kbd> to initiate text selection (as a remainder, on kindle text selection in reader is initiated with <kbd>up</kbd>/<kbd>down</kbd> so it is fairly similar). At this point, FocusManager is disabled, allowing the cursor keys (and press) to control the now-visible crosshairs. Pressing <kbd>back</kbd> should stop text selection and restore FocusManager’s control of the widget.
This commit is contained in:
David
2025-02-17 20:41:34 +00:00
committed by GitHub
parent cee259b885
commit 546cb14ccc
3 changed files with 327 additions and 31 deletions

View File

@@ -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, {

View File

@@ -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

View File

@@ -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