mirror of
https://github.com/koreader/koreader
synced 2024-11-16 06:12:56 +00:00
textboxwidget and scrolltextwidget enhancements (#2393)
util: made isSplitable() accept an optional next_char for wiser decision textboxwidget: speed up rendering, enhanced text wrapping, allow selection of multiple words with Hold. scrolltextwidget: allow scrolling with Tap. Details in #2393
This commit is contained in:
parent
d1f9cf932b
commit
5040bfe4c5
@ -50,7 +50,7 @@ function ScrollTextWidget:init()
|
||||
}
|
||||
local horizontal_group = HorizontalGroup:new{}
|
||||
table.insert(horizontal_group, self.text_widget)
|
||||
table.insert(horizontal_group, HorizontalSpan:new{self.text_scroll_span})
|
||||
table.insert(horizontal_group, HorizontalSpan:new{width=self.text_scroll_span})
|
||||
table.insert(horizontal_group, self.v_scroll_bar)
|
||||
self[1] = horizontal_group
|
||||
self.dimen = Geom:new(self[1]:getSize())
|
||||
@ -62,6 +62,12 @@ function ScrollTextWidget:init()
|
||||
range = function() return self.dimen end,
|
||||
},
|
||||
},
|
||||
TapScrollText = { -- allow scrolling with tap
|
||||
GestureRange:new{
|
||||
ges = "tap",
|
||||
range = function() return self.dimen end,
|
||||
},
|
||||
},
|
||||
}
|
||||
end
|
||||
if Device:hasKeyboard() or Device:hasKeys() then
|
||||
@ -101,10 +107,30 @@ end
|
||||
function ScrollTextWidget:onScrollText(arg, ges)
|
||||
if ges.direction == "north" then
|
||||
self:scrollText(1)
|
||||
return true
|
||||
elseif ges.direction == "south" then
|
||||
self:scrollText(-1)
|
||||
return true
|
||||
end
|
||||
return true
|
||||
-- if swipe west/east, let it propagate up (e.g. for quickdictlookup to
|
||||
-- go to next/prev result)
|
||||
end
|
||||
|
||||
function ScrollTextWidget:onTapScrollText(arg, ges)
|
||||
-- same tests as done in TextBoxWidget:scrollUp/Down
|
||||
if ges.pos.x < Screen:getWidth()/2 then
|
||||
if self.text_widget.virtual_line_num > 1 then
|
||||
self:scrollText(-1)
|
||||
return true
|
||||
end
|
||||
else
|
||||
if self.text_widget.virtual_line_num + self.text_widget:getVisLineCount() <= #self.text_widget.vertical_string_list then
|
||||
self:scrollText(1)
|
||||
return true
|
||||
end
|
||||
end
|
||||
-- if we couldn't scroll (because we're already at top or bottom),
|
||||
-- let it propagate up (e.g. for quickdictlookup to go to next/prev result)
|
||||
end
|
||||
|
||||
function ScrollTextWidget:onScrollDown()
|
||||
|
@ -20,6 +20,7 @@ local Screen = require("device").screen
|
||||
local Geom = require("ui/geometry")
|
||||
local util = require("util")
|
||||
local DEBUG= require("dbg")
|
||||
local TimeVal = require("ui/timeval")
|
||||
|
||||
local TextBoxWidget = Widget:new{
|
||||
text = nil,
|
||||
@ -79,8 +80,14 @@ function TextBoxWidget:_evalCharWidthList()
|
||||
self.charpos = #self.charlist + 1
|
||||
end
|
||||
self.char_width_list = {}
|
||||
-- use a cache to avoid many calls to RenderText:sizeUtf8Text()
|
||||
local char_width_cache = {}
|
||||
for _, v in ipairs(self.charlist) do
|
||||
local w = RenderText:sizeUtf8Text(0, Screen:getWidth(), self.face, v, true, self.bold).x
|
||||
local w = char_width_cache[v]
|
||||
if w == nil then
|
||||
w = RenderText:sizeUtf8Text(0, Screen:getWidth(), self.face, v, true, self.bold).x
|
||||
char_width_cache[v] = w
|
||||
end
|
||||
table.insert(self.char_width_list, {char = v, width = w})
|
||||
end
|
||||
end
|
||||
@ -112,7 +119,9 @@ function TextBoxWidget:_splitCharWidthList()
|
||||
else
|
||||
-- Backtrack the string until the length fit into one line.
|
||||
local c = self.char_width_list[idx].char
|
||||
if util.isSplitable(c) then
|
||||
-- We give next char to isSplitable() for a wiser decision
|
||||
local next_c = idx+1 <= size and self.char_width_list[idx+1].char or nil
|
||||
if util.isSplitable(c, next_c) then
|
||||
cur_line_text = table.concat(self.charlist, "", offset, idx - 1)
|
||||
cur_line_width = cur_line_width - self.char_width_list[idx].width
|
||||
else
|
||||
@ -122,8 +131,9 @@ function TextBoxWidget:_splitCharWidthList()
|
||||
adjusted_width = adjusted_width - self.char_width_list[adjusted_idx].width
|
||||
if adjusted_idx == 1 then break end
|
||||
adjusted_idx = adjusted_idx - 1
|
||||
next_c = c
|
||||
c = self.char_width_list[adjusted_idx].char
|
||||
until adjusted_idx > offset and util.isSplitable(c)
|
||||
until adjusted_idx == offset or util.isSplitable(c, next_c)
|
||||
if adjusted_idx == offset then -- a very long english word ocuppying more than one line
|
||||
cur_line_text = table.concat(self.charlist, "", offset, idx - 1)
|
||||
cur_line_width = cur_line_width - self.char_width_list[idx].width
|
||||
@ -144,6 +154,12 @@ function TextBoxWidget:_splitCharWidthList()
|
||||
idx = idx + 1
|
||||
-- FIXME: reuse newline entry
|
||||
self.vertical_string_list[ln+1] = {text = "", offset = idx, width = 0}
|
||||
else
|
||||
-- If next char is a space, discard it so it does not become
|
||||
-- an ugly leading space on the next line
|
||||
if idx <= size and self.char_width_list[idx].char == " " then
|
||||
idx = idx + 1
|
||||
end
|
||||
end
|
||||
ln = ln + 1
|
||||
-- Make sure `idx` point to the next char to be processed in the next loop.
|
||||
@ -288,6 +304,7 @@ function TextBoxWidget:free()
|
||||
end
|
||||
end
|
||||
|
||||
-- Allow selection of a single word at hold position
|
||||
function TextBoxWidget:onHoldWord(callback, ges)
|
||||
if not callback then return end
|
||||
|
||||
@ -333,4 +350,117 @@ function TextBoxWidget:onHoldWord(callback, ges)
|
||||
return
|
||||
end
|
||||
|
||||
|
||||
-- Allow selection of one or more words (with no visual feedback)
|
||||
-- Gestures should be declared in widget using us (e.g dictquicklookup.lua)
|
||||
|
||||
-- Constants for which side of a word to find
|
||||
local FIND_START = 1
|
||||
local FIND_END = 2
|
||||
|
||||
function TextBoxWidget:onHoldStartText(_, ges)
|
||||
-- just store hold start position and timestamp, will be used on release
|
||||
self.hold_start_x = ges.pos.x - self.dimen.x
|
||||
self.hold_start_y = ges.pos.y - self.dimen.y
|
||||
self.hold_start_tv = TimeVal.now()
|
||||
return true
|
||||
end
|
||||
|
||||
function TextBoxWidget:onHoldReleaseText(callback, ges)
|
||||
if not callback then return end
|
||||
|
||||
local hold_end_x = ges.pos.x - self.dimen.x
|
||||
local hold_end_y = ges.pos.y - self.dimen.y
|
||||
local hold_duration = TimeVal.now() - self.hold_start_tv
|
||||
hold_duration = hold_duration.sec + hold_duration.usec/1000000
|
||||
|
||||
-- Swap start and end if needed
|
||||
local x0, y0, x1, y1
|
||||
-- first, sort by y/line_num
|
||||
local start_line_num = math.ceil(self.hold_start_y / self.line_height_px)
|
||||
local end_line_num = math.ceil(hold_end_y / self.line_height_px)
|
||||
if start_line_num < end_line_num then
|
||||
x0, y0 = self.hold_start_x, self.hold_start_y
|
||||
x1, y1 = hold_end_x, hold_end_y
|
||||
elseif start_line_num > end_line_num then
|
||||
x0, y0 = hold_end_x, hold_end_y
|
||||
x1, y1 = self.hold_start_x, self.hold_start_y
|
||||
else -- same line_num : sort by x
|
||||
if self.hold_start_x <= hold_end_x then
|
||||
x0, y0 = self.hold_start_x, self.hold_start_y
|
||||
x1, y1 = hold_end_x, hold_end_y
|
||||
else
|
||||
x0, y0 = hold_end_x, hold_end_y
|
||||
x1, y1 = self.hold_start_x, self.hold_start_y
|
||||
end
|
||||
end
|
||||
|
||||
-- similar code to find start or end is in _findWordEdge() helper
|
||||
local sel_start_idx = self:_findWordEdge(x0, y0, FIND_START)
|
||||
local sel_end_idx = self:_findWordEdge(x1, y1, FIND_END)
|
||||
|
||||
if not sel_start_idx or not sel_end_idx then
|
||||
-- one or both hold points were out of text
|
||||
return true
|
||||
end
|
||||
|
||||
local selected_text = table.concat(self.charlist, "", sel_start_idx, sel_end_idx)
|
||||
DEBUG("onHoldReleaseText (duration:", hold_duration, ") :", sel_start_idx, ">", sel_end_idx, "=", selected_text)
|
||||
callback(selected_text, hold_duration)
|
||||
return true
|
||||
end
|
||||
|
||||
function TextBoxWidget:_findWordEdge(x, y, side)
|
||||
if side ~= FIND_START and side ~= FIND_END then
|
||||
return
|
||||
end
|
||||
local line_num = math.ceil(y / self.line_height_px) + self.virtual_line_num-1
|
||||
local line = self.vertical_string_list[line_num]
|
||||
if not line then
|
||||
return -- below last line : no selection
|
||||
end
|
||||
local char_start = line.offset
|
||||
local char_end -- char_end is non-inclusive
|
||||
if line_num >= #self.vertical_string_list then
|
||||
char_end = #self.char_width_list + 1
|
||||
else
|
||||
char_end = self.vertical_string_list[line_num+1].offset
|
||||
end
|
||||
local char_probe_x = 0
|
||||
local idx = char_start
|
||||
local edge_idx = nil
|
||||
-- find which character the touch is holding
|
||||
while idx < char_end do
|
||||
local c = self.char_width_list[idx]
|
||||
char_probe_x = char_probe_x + c.width
|
||||
if char_probe_x > x then
|
||||
-- character found, find which word the character is in, and
|
||||
-- get its start/end idx
|
||||
local words = util.splitToWords(line.text)
|
||||
-- words may contain separators (space, punctuation) : we don't
|
||||
-- discriminate here, it's the caller job to clean what was
|
||||
-- selected
|
||||
local probe_idx = char_start
|
||||
local next_probe_idx
|
||||
for _, w in ipairs(words) do
|
||||
next_probe_idx = probe_idx + #util.splitToChars(w)
|
||||
if idx < next_probe_idx then
|
||||
if side == FIND_START then
|
||||
edge_idx = probe_idx
|
||||
elseif side == FIND_END then
|
||||
edge_idx = next_probe_idx - 1
|
||||
end
|
||||
break
|
||||
end
|
||||
probe_idx = next_probe_idx
|
||||
end
|
||||
if edge_idx then
|
||||
break
|
||||
end
|
||||
end
|
||||
idx = idx + 1
|
||||
end
|
||||
return edge_idx
|
||||
end
|
||||
|
||||
return TextBoxWidget
|
||||
|
@ -146,9 +146,34 @@ function util.splitToWords(text)
|
||||
return wlist
|
||||
end
|
||||
|
||||
-- Test whether a string could be separated by a char for multi-line rendering
|
||||
function util.isSplitable(c)
|
||||
return util.isCJKChar(c) or c == " " or string.match(c, "%p") ~= nil
|
||||
-- We don't want to split on a space if it is followed by some
|
||||
-- specific punctuation : e.g. "word :" or "word )"
|
||||
-- (In french, there is a space before a colon, and it better
|
||||
-- not be wrapped there.)
|
||||
-- Includes U+00BB >> (right double angle quotation mark) and
|
||||
-- U+201D '' (right double quotation mark)
|
||||
local non_splitable_space_tailers = ":;,.!?)]}$%-=/<>»”"
|
||||
|
||||
-- Test whether a string could be separated by this char for multi-line rendering
|
||||
-- Optional next char may be provided to help make the decision
|
||||
function util.isSplitable(c, next_c)
|
||||
if util.isCJKChar(c) then
|
||||
-- a CJKChar is a word in itself, and so is splitable
|
||||
return true
|
||||
elseif c == " " then
|
||||
-- we only split on a space (so punctuation sticks to prev word)
|
||||
-- if next_c is provided, we can make a better decision
|
||||
if next_c and non_splitable_space_tailers:find(next_c, 1, true) then
|
||||
-- this space is followed by some punctuation that is better
|
||||
-- kept with us along previous word
|
||||
return false
|
||||
else
|
||||
-- we can split on this space
|
||||
return true
|
||||
end
|
||||
end
|
||||
-- otherwise, non splitable
|
||||
return false
|
||||
end
|
||||
|
||||
return util
|
||||
|
@ -93,14 +93,12 @@ describe("util module", function()
|
||||
if i == #table_chars then table.insert(table_of_words, word) end
|
||||
end
|
||||
assert.are_same(table_of_words, {
|
||||
"Pójdźże,",
|
||||
" ",
|
||||
"Pójdźże, ",
|
||||
"chmurność ",
|
||||
"glück ",
|
||||
"schließen ",
|
||||
"Štěstí ",
|
||||
"neštěstí.",
|
||||
" ",
|
||||
"neštěstí. ",
|
||||
"Uñas ",
|
||||
"gavilán",
|
||||
})
|
||||
@ -126,4 +124,38 @@ describe("util module", function()
|
||||
})
|
||||
end)
|
||||
|
||||
it("should split text to line with next_c - unicode", function()
|
||||
local text = "Ce test : 1) est très simple ; 2 ) simple comme ( 2/2 ) > 50 % ? ok."
|
||||
local word = ""
|
||||
local table_of_words = {}
|
||||
local c
|
||||
local table_chars = util.splitToChars(text)
|
||||
for i = 1, #table_chars do
|
||||
c = table_chars[i]
|
||||
next_c = i < #table_chars and table_chars[i+1] or nil
|
||||
word = word .. c
|
||||
if util.isSplitable(c, next_c) then
|
||||
table.insert(table_of_words, word)
|
||||
word = ""
|
||||
end
|
||||
if i == #table_chars then table.insert(table_of_words, word) end
|
||||
end
|
||||
assert.are_same(table_of_words, {
|
||||
"Ce ",
|
||||
"test : ",
|
||||
"1) ",
|
||||
"est ",
|
||||
"très ",
|
||||
"simple ; ",
|
||||
"2 ) ",
|
||||
"simple ",
|
||||
"comme ",
|
||||
"( ",
|
||||
"2/2 ) > ",
|
||||
"50 % ? ",
|
||||
"ok."
|
||||
})
|
||||
end)
|
||||
|
||||
|
||||
end)
|
||||
|
Loading…
Reference in New Issue
Block a user