diff --git a/frontend/ui/widget/inputtext.lua b/frontend/ui/widget/inputtext.lua index ebb6ace09..95361f6e6 100644 --- a/frontend/ui/widget/inputtext.lua +++ b/frontend/ui/widget/inputtext.lua @@ -8,16 +8,17 @@ local UIManager = require("ui/uimanager") local Device = require("device") local Screen = Device.screen local Font = require("ui/font") -local util = require("ffi/util") +local util = require("util") local Keyboard local InputText = InputContainer:new{ text = "", hint = "demo hint", - charlist = {}, -- table to store input string - charpos = 1, + charlist = nil, -- table to store input string + charpos = nil, -- position to insert a new char, or the position of the cursor input_type = nil, text_type = nil, + text_widget = nil, -- Text Widget for cursor movement width = nil, height = nil, @@ -46,9 +47,18 @@ if Device.isTouchDevice() then } end - function InputText:onTapTextBox() + function InputText:onTapTextBox(arg, ges) if self.parent.onSwitchFocus then self.parent:onSwitchFocus(self) + else + local x = ges.pos.x - self.dimen.x - self.bordersize - self.padding + local y = ges.pos.y - self.dimen.y - self.bordersize - self.padding + if x > 0 and y > 0 then + self.charpos = self.text_widget:moveCursor(x, y) + UIManager:setDirty(self.parent, function() + return "ui", self[1].dimen + end) + end end end else @@ -64,28 +74,34 @@ end function InputText:initTextBox(text) self.text = text - self:initCharlist(text) + self.charlist = util.splitToChars(text) + if self.charpos == nil then + self.charpos = #self.charlist + 1 + end local fgcolor = Blitbuffer.gray(self.text == "" and 0.5 or 1.0) local show_text = self.text if self.text_type == "password" and show_text ~= "" then show_text = self.text:gsub("(.-).", function() return "*" end) show_text = show_text:gsub("(.)$", function() return self.text:sub(-1) end) - elseif show_text == "" then - show_text = self.hint end - local text_widget if self.scroll then - text_widget = ScrollTextWidget:new{ + self.text_widget = ScrollTextWidget:new{ text = show_text, + charlist = self.charlist, + charpos = self.charpos, + editable = true, face = self.face, fgcolor = fgcolor, width = self.width, height = self.height, } else - text_widget = TextBoxWidget:new{ + self.text_widget = TextBoxWidget:new{ text = show_text, + charlist = self.charlist, + charpos = self.charpos, + editable = true, face = self.face, fgcolor = fgcolor, width = self.width, @@ -97,7 +113,7 @@ function InputText:initTextBox(text) padding = self.padding, margin = self.margin, color = Blitbuffer.gray(self.focused and 1.0 or 0.5), - text_widget, + self.text_widget, } self.dimen = self[1]:getSize() -- FIXME: self.parent is not always in the widget statck (BookStatusWidget) @@ -106,22 +122,6 @@ function InputText:initTextBox(text) end) end -function InputText:initCharlist(text) - if text == nil then return end - -- clear - self.charlist = {} - self.charpos = 1 - local prevcharcode, charcode = 0 - for uchar in string.gfind(text, "([%z\1-\127\194-\244][\128-\191]*)") do - charcode = util.utf8charcode(uchar) - if prevcharcode then -- utf8 - self.charlist[#self.charlist+1] = uchar - end - prevcharcode = charcode - end - self.charpos = #self.charlist+1 -end - function InputText:initKeyboard() local keyboard_layout = 2 if self.input_type == "number" then diff --git a/frontend/ui/widget/scrolltextwidget.lua b/frontend/ui/widget/scrolltextwidget.lua index 85018f248..716b5c34e 100644 --- a/frontend/ui/widget/scrolltextwidget.lua +++ b/frontend/ui/widget/scrolltextwidget.lua @@ -16,6 +16,9 @@ Text widget with vertical scroll bar --]] local ScrollTextWidget = InputContainer:new{ text = nil, + charlist = nil, + charpos = nil, + editable = false, face = nil, fgcolor = Blitbuffer.COLOR_BLACK, width = 400, @@ -28,6 +31,9 @@ local ScrollTextWidget = InputContainer:new{ function ScrollTextWidget:init() self.text_widget = TextBoxWidget:new{ text = self.text, + charlist = self.charlist, + charpos = self.charpos, + editable = self.editable, face = self.face, fgcolor = self.fgcolor, width = self.width - self.scroll_bar_width - self.text_scroll_span, @@ -39,12 +45,12 @@ function ScrollTextWidget:init() enable = visible_line_count < total_line_count, low = 0, high = visible_line_count/total_line_count, - width = Screen:scaleBySize(6), + width = self.scroll_bar_width, height = self.height, } local horizontal_group = HorizontalGroup:new{} table.insert(horizontal_group, self.text_widget) - table.insert(horizontal_group, HorizontalSpan:new{width = Screen:scaleBySize(6)}) + table.insert(horizontal_group, HorizontalSpan:new{self.text_scroll_span}) table.insert(horizontal_group, self.v_scroll_bar) self[1] = horizontal_group self.dimen = Geom:new(self[1]:getSize()) @@ -66,24 +72,15 @@ function ScrollTextWidget:init() end end -function ScrollTextWidget:updateScrollBar(text) - local virtual_line_num = text:getVirtualLineNum() - local visible_line_count = text:getVisLineCount() - local all_line_count = text:getAllLineCount() - self.v_scroll_bar:set( - (virtual_line_num - 1) / all_line_count, - (virtual_line_num - 1 + visible_line_count) / all_line_count - ) -end - function ScrollTextWidget:scrollText(direction) if direction == 0 then return end + local low, high if direction > 0 then - self.text_widget:scrollDown() + low, high = self.text_widget:scrollDown() else - self.text_widget:scrollUp() + low, high = self.text_widget:scrollUp() end - self:updateScrollBar(self.text_widget) + self.v_scroll_bar:set(low, high) UIManager:setDirty(self.dialog, function() return "partial", self.dimen end) diff --git a/frontend/ui/widget/textboxwidget.lua b/frontend/ui/widget/textboxwidget.lua index ad3a172d0..0d10897df 100644 --- a/frontend/ui/widget/textboxwidget.lua +++ b/frontend/ui/widget/textboxwidget.lua @@ -1,5 +1,6 @@ local Blitbuffer = require("ffi/blitbuffer") local Widget = require("ui/widget/widget") +local LineWidget = require("ui/widget/linewidget") local RenderText = require("ui/rendertext") local Screen = require("device").screen local Geom = require("ui/geometry") @@ -11,193 +12,134 @@ A TextWidget that handles long text wrapping --]] local TextBoxWidget = Widget:new{ text = nil, + charlist = nil, + charpos = nil, + char_width_list = nil, -- list of widths of the chars in `charlist`. + vertical_string_list = nil, + editable = false, -- Editable flag for whether drawing the cursor or not. + cursor_line = nil, -- LineWidget to draw the vertical cursor. face = nil, bold = nil, + line_height = 0.3, -- in em fgcolor = Blitbuffer.COLOR_BLACK, width = 400, -- in pixels - height = nil, - first_line = 1, - virtual_line = 1, -- used by scroll bar - line_height = 0.3, -- in em - v_list = nil, + height = nil, -- nil value indicates unscrollable text widget + virtual_line_num = 1, -- used by scroll bar _bb = nil, - _length = 0, } function TextBoxWidget:init() - local v_list - if self.height then - v_list = self:_getCurrentVerticalList() + local line_height = (1 + self.line_height) * self.face.size + self.cursor_line = LineWidget:new{ + dimen = Geom:new{ + w = Screen:scaleBySize(1), + h = line_height, + } + } + self:_evalCharWidthList() + self:_splitCharWidthList() + if self.height == nil then + self:_renderText(1, #self.vertical_string_list) else - v_list = self:_getVerticalList() + self:_renderText(1, self:getVisLineCount()) + end + if self.editable then + local x, y + x, y = self:_findCharPos() + self.cursor_line:paintTo(self._bb, x, y) end - self:_render(v_list) self.dimen = Geom:new(self:getSize()) end -function TextBoxWidget:_wrapGreedyAlg(h_list) - local line_height = (1 + self.line_height) * self.face.size - local cur_line_width = 0 - local cur_line = {} - local v_list = {} - - for k,w in ipairs(h_list) do - w.box = { - x = cur_line_width, - w = w.width, - h = line_height, - } - cur_line_width = cur_line_width + w.width - if w.word == "\n" then - if cur_line_width > 0 then - -- hard line break - table.insert(v_list, cur_line) - cur_line = {} - cur_line_width = 0 - end - elseif cur_line_width > self.width then - -- wrap to next line - table.insert(v_list, cur_line) - cur_line = {} - cur_line_width = w.width - table.insert(cur_line, w) - else - table.insert(cur_line, w) - end +-- Evaluate the width of each char in `self.charlist`. +function TextBoxWidget:_evalCharWidthList() + if self.charlist == nil then + self.charlist = util.splitToChars(self.text) + self.charpos = #self.charlist + 1 + end + self.char_width_list = {} + for _, v in ipairs(self.charlist) do + local w = RenderText:sizeUtf8Text(0, Screen:getWidth(), self.face, v, true, self.bold).x + table.insert(self.char_width_list, {char = v, width = w}) end - -- handle last line - table.insert(v_list, cur_line) - - return v_list end -function TextBoxWidget:_getVerticalList(alg) - if self.vertical_list then - return self.vertical_list - end - -- build horizontal list - local h_list = {} - for line in util.gsplit(self.text, "\n", true) do - for words in line:gmatch("[\32-\127\192-\255]+[\128-\191]*") do - for word in util.gsplit(words, "%s+", true) do - for w in util.gsplit(word, "%p+", true) do - local word_box = {} - word_box.word = w - word_box.width = RenderText:sizeUtf8Text(0, Screen:getWidth(), self.face, w, true, self.bold).x - table.insert(h_list, word_box) +-- Split the text into logical lines to fit into the text box. +function TextBoxWidget:_splitCharWidthList() + self.vertical_string_list = {} + self.vertical_string_list[1] = {text = "Demo hint", offset = 1, width = 0} -- hint for empty string + + local idx = 1 + local size = #self.char_width_list + local ln = 1 + local offset, cur_line_width, cur_line_text + while idx <= size do + offset = idx + -- Appending chars until the accumulated width exceeds `self.width`, + -- or a newline occurs, or no more chars to consume. + cur_line_width = 0 + local hard_newline = false + while idx <= size do + if self.char_width_list[idx].char == "\n" then + hard_newline = true + break + end + cur_line_width = cur_line_width + self.char_width_list[idx].width + if cur_line_width > self.width then break else idx = idx + 1 end + end + if cur_line_width <= self.width then -- a hard newline or end of string + cur_line_text = table.concat(self.charlist, "", offset, idx - 1) + else + -- Backtrack the string until the length fit into one line. + local c = self.char_width_list[idx].char + if util.isSplitable(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 + local adjusted_idx = idx + local adjusted_width = cur_line_width + repeat + adjusted_width = adjusted_width - self.char_width_list[adjusted_idx].width + adjusted_idx = adjusted_idx - 1 + c = self.char_width_list[adjusted_idx].char + until adjusted_idx > offset and util.isSplitable(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 + else + cur_line_text = table.concat(self.charlist, "", offset, adjusted_idx) + cur_line_width = adjusted_width + idx = adjusted_idx + 1 end - end + end -- endif util.isSplitable(c) + end -- endif cur_line_width > self.width + self.vertical_string_list[ln] = {text = cur_line_text, offset = offset, width = cur_line_width} + if hard_newline then + idx = idx + 1 + self.vertical_string_list[ln + 1] = {text = "", offset = idx, width = 0} end - if line:sub(-1) == "\n" then table.insert(h_list, {word = '\n', width = 0}) end + ln = ln + 1 + -- Make sure `idx` point to the next char to be processed in the next loop. end - - -- @TODO check alg here 25.04 2012 (houqp) - -- @TODO replace greedy algorithm with K&P algorithm 25.04 2012 (houqp) - self.vertical_list = self:_wrapGreedyAlg(h_list) - return self.vertical_list end -function TextBoxWidget:_getCurrentVerticalList() - local line_height = (1 + self.line_height) * self.face.size - local v_list = self:_getVerticalList() - local current_v_list = {} - local height = 0 - for i = self.first_line, #v_list do - if height < self.height - line_height then - table.insert(current_v_list, v_list[i]) - height = height + line_height - else - break - end - end - return current_v_list -end - -function TextBoxWidget:_getPreviousVerticalList() - local line_height = (1 + self.line_height) * self.face.size - local v_list = self:_getVerticalList() - local previous_v_list = {} - local height = 0 - if self.first_line == 1 then - return self:_getCurrentVerticalList() - end - self.virtual_line = self.first_line - for i = self.first_line - 1, 1, -1 do - if height < self.height - line_height then - table.insert(previous_v_list, 1, v_list[i]) - height = height + line_height - self.virtual_line = self.virtual_line - 1 - else - break - end - end - for i = self.first_line, #v_list do - if height < self.height - line_height then - table.insert(previous_v_list, v_list[i]) - height = height + line_height - else - break - end - end - if self.first_line > #previous_v_list then - self.first_line = self.first_line - #previous_v_list - else - self.first_line = 1 - end - return previous_v_list -end - -function TextBoxWidget:_getNextVerticalList() - local line_height = (1 + self.line_height) * self.face.size - local v_list = self:_getVerticalList() - local current_v_list = self:_getCurrentVerticalList() - local next_v_list = {} - local height = 0 - if self.first_line + #current_v_list > #v_list then - return current_v_list - end - self.virtual_line = self.first_line - for i = self.first_line + #current_v_list, #v_list do - if height < self.height - line_height then - table.insert(next_v_list, v_list[i]) - height = height + line_height - self.virtual_line = self.virtual_line + 1 - else - break - end - end - self.first_line = self.first_line + #current_v_list - return next_v_list -end - -function TextBoxWidget:_render(v_list) - self.rendering_vlist = v_list +function TextBoxWidget:_renderText(start_row_idx, end_row_idx) local font_height = self.face.size - local line_height_px = self.line_height * font_height - local h = (font_height + line_height_px) * #v_list + local line_height = (1 + self.line_height) * font_height + if start_row_idx < 1 then start_row_idx = 1 end + if end_row_idx > #self.vertical_string_list then end_row_idx = #self.vertical_string_list end + local row_count = end_row_idx == 0 and 1 or end_row_idx - start_row_idx + 1 + local h = line_height * row_count self._bb = Blitbuffer.new(self.width, h) self._bb:fill(Blitbuffer.COLOR_WHITE) local y = font_height - local pen_x - for _,l in ipairs(v_list) do - if self.alignment == "center" then - local line_len = 0 - for _,w in ipairs(l) do - line_len = line_len + w.width - end - pen_x = (self.width - line_len)/2 - else - pen_x = 0 - end - - for _,w in ipairs(l) do - w.box.y = y - line_height_px - font_height + for i = start_row_idx, end_row_idx do + local line = self.vertical_string_list[i] + local pen_x = self.alignment == "center" and (self.width - line.width)/2 or 0 --@TODO Don't use kerning for monospaced fonts. (houqp) -- refert to cb25029dddc42693cc7aaefbe47e9bd3b7e1a750 in master tree - RenderText:renderUtf8Text(self._bb, pen_x, y, self.face, w.word, true, self.bold, self.fgcolor) - pen_x = pen_x + w.width - end - y = y + line_height_px + font_height + RenderText:renderUtf8Text(self._bb, pen_x, y, self.face, line.text, true, self.bold, self.fgcolor) + y = y + line_height end -- -- if text is shorter than one line, shrink to text's width -- if #v_list == 1 then @@ -205,13 +147,52 @@ function TextBoxWidget:_render(v_list) -- end end -function TextBoxWidget:getVirtualLineNum() - return self.virtual_line +-- Return the position of the cursor corresponding to `self.charpos`, +-- Be aware of virtual line number of the scorllTextWidget. +function TextBoxWidget:_findCharPos() + -- Find the line number. + local ln = self.height == nil and 1 or self.virtual_line_num + while ln + 1 <= #self.vertical_string_list do + if self.vertical_string_list[ln + 1].offset > self.charpos then break else ln = ln + 1 end + end + -- Find the offset at the current line. + local x = 0 + local offset = self.vertical_string_list[ln].offset + while offset < self.charpos do + x = x + self.char_width_list[offset].width + offset = offset + 1 + end + local line_height = (1 + self.line_height) * self.face.size + return x + 1, (ln - 1) * line_height -- offset `x` by 1 to avoid overlap end -function TextBoxWidget:getAllLineCount() - local v_list = self:_getVerticalList() - return #v_list +-- Click event: Move the cursor to a new location with (x, y), in pixels. +-- Be aware of virtual line number of the scorllTextWidget. +function TextBoxWidget:moveCursor(x, y) + local w = 0 + local line_height = (1 + self.line_height) * self.face.size + local ln = self.height == nil and 1 or self.virtual_line_num + ln = ln + math.ceil(y / line_height) - 1 + if ln > #self.vertical_string_list then + ln = #self.vertical_string_list + x = self.width + end + local offset = self.vertical_string_list[ln].offset + local idx = ln == #self.vertical_string_list and #self.char_width_list or self.vertical_string_list[ln + 1].offset - 1 + while offset <= idx do + w = w + self.char_width_list[offset].width + if w > x then break else offset = offset + 1 end + end + if w > x then + local w_prev = w - self.char_width_list[offset].width + if x - w_prev < w - x then -- the previous one is more closer + w = w_prev + end + end + self:free() + self:_renderText(1, #self.vertical_string_list) + self.cursor_line:paintTo(self._bb, w + 1, (ln - self.virtual_line_num) * line_height) + return offset end function TextBoxWidget:getVisLineCount() @@ -219,16 +200,35 @@ function TextBoxWidget:getVisLineCount() return math.floor(self.height / line_height) end -function TextBoxWidget:scrollDown() - local next_v_list = self:_getNextVerticalList() - self:free() - self:_render(next_v_list) +function TextBoxWidget:getAllLineCount() + return #self.vertical_string_list end + +-- TODO: modify `charpos` so that it can render the cursor +function TextBoxWidget:scrollDown() + local visible_line_count = self:getVisLineCount() + if self.virtual_line_num + visible_line_count <= #self.vertical_string_list then + self:free() + self.virtual_line_num = self.virtual_line_num + visible_line_count + self:_renderText(self.virtual_line_num, self.virtual_line_num + visible_line_count - 1) + end + return (self.virtual_line_num - 1) / #self.vertical_string_list, (self.virtual_line_num - 1 + visible_line_count) / #self.vertical_string_list +end + +-- TODO: modify `charpos` so that it can render the cursor function TextBoxWidget:scrollUp() - local previous_v_list = self:_getPreviousVerticalList() - self:free() - self:_render(previous_v_list) + local visible_line_count = self:getVisLineCount() + if self.virtual_line_num > 1 then + self:free() + if self.virtual_line_num <= visible_line_count then + self.virtual_line_num = 1 + else + self.virtual_line_num = self.virtual_line_num - visible_line_count + end + self:_renderText(self.virtual_line_num, self.virtual_line_num + visible_line_count - 1) + end + return (self.virtual_line_num - 1) / #self.vertical_string_list, (self.virtual_line_num - 1 + visible_line_count) / #self.vertical_string_list end function TextBoxWidget:getSize() diff --git a/frontend/util.lua b/frontend/util.lua index d7a4d6330..544863d7a 100644 --- a/frontend/util.lua +++ b/frontend/util.lua @@ -1,3 +1,5 @@ +local BaseUtil = require("ffi/util") + --[[-- Miscellaneous helper functions for KOReader frontend. ]] @@ -94,4 +96,27 @@ function util.lastIndexOf(string, ch) if i == nil then return -1 else return i - 1 end end + +-- Split string into a list of UTF-8 chars. +-- @text: the string to be splitted. +function util.splitToChars(text) + local tab = {} + if text ~= nil then + local prevcharcode, charcode = 0 + for uchar in string.gfind(text, "([%z\1-\127\194-\244][\128-\191]*)") do + charcode = BaseUtil.utf8charcode(uchar) + if prevcharcode then -- utf8 + table.insert(tab, uchar) + end + prevcharcode = charcode + end + end + return tab +end + +-- Test whether a string could be separated by a char for multi-line rendering +function util.isSplitable(c) + return #c > 1 or c == " " or string.match(c, "%p") ~= nil +end + return util