From 5040bfe4c5d60e4588998cd55c7c9ccd9947af4b Mon Sep 17 00:00:00 2001 From: poire-z Date: Tue, 6 Dec 2016 22:10:25 +0100 Subject: [PATCH] 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 --- frontend/ui/widget/scrolltextwidget.lua | 30 +++++- frontend/ui/widget/textboxwidget.lua | 136 +++++++++++++++++++++++- frontend/util.lua | 31 +++++- spec/unit/util_spec.lua | 40 ++++++- 4 files changed, 225 insertions(+), 12 deletions(-) diff --git a/frontend/ui/widget/scrolltextwidget.lua b/frontend/ui/widget/scrolltextwidget.lua index 93cba83dd..d1cafb597 100644 --- a/frontend/ui/widget/scrolltextwidget.lua +++ b/frontend/ui/widget/scrolltextwidget.lua @@ -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() diff --git a/frontend/ui/widget/textboxwidget.lua b/frontend/ui/widget/textboxwidget.lua index 8058c03d3..1f4d53ee0 100644 --- a/frontend/ui/widget/textboxwidget.lua +++ b/frontend/ui/widget/textboxwidget.lua @@ -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 diff --git a/frontend/util.lua b/frontend/util.lua index 2272e9feb..bd0b5726b 100644 --- a/frontend/util.lua +++ b/frontend/util.lua @@ -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 diff --git a/spec/unit/util_spec.lua b/spec/unit/util_spec.lua index 7ebdbf6e6..557b4905b 100644 --- a/spec/unit/util_spec.lua +++ b/spec/unit/util_spec.lua @@ -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)