mirror of
https://github.com/koreader/koreader.git
synced 2025-12-13 20:36:53 +01:00
InputText: checks whether provided content can be given back unaltered, which may not be the case after it is splitted to UTF8 chars if the text is binary content. Prevent editing text if that is the case. Adds InputText and InputDialog :isEditable() and :isEdited() methods. Also accounts for the scrollbar width when measuring text to prevent it from being displayed when not needed. Also ensure a minimal size of the scrollbar thumb so it is rendered when huge text with many lines is displayed. Virtual keyboard: Hold on Backspace: delete from cursor to start of line instead of clearing all text content.
418 lines
14 KiB
Lua
418 lines
14 KiB
Lua
--[[--
|
|
Widget for taking user input.
|
|
|
|
Example:
|
|
|
|
local UIManager = require("ui/uimanager")
|
|
local _ = require("gettext")
|
|
local sample_input
|
|
sample_input = InputDialog:new{
|
|
title = _("Dialog title"),
|
|
input = "default value",
|
|
input_hint = "hint text",
|
|
input_type = "string",
|
|
description = "Some more description",
|
|
-- text_type = "password",
|
|
buttons = {
|
|
{
|
|
{
|
|
text = _("Cancel"),
|
|
callback = function()
|
|
UIManager:close(sample_input)
|
|
end,
|
|
},
|
|
{
|
|
text = _("Save"),
|
|
-- button with is_enter_default set to true will be
|
|
-- triggered after user press the enter key from keyboard
|
|
is_enter_default = true,
|
|
callback = function()
|
|
print('Got user input as raw text:', sample_input:getInputText())
|
|
print('Got user input as value:', sample_input:getInputValue())
|
|
end,
|
|
},
|
|
}
|
|
},
|
|
}
|
|
UIManager:show(sample_input)
|
|
sample_input:onShowKeyboard()
|
|
|
|
To get a full screen text editor, use:
|
|
fullscreen = true, -- no need to provide any height and width
|
|
condensed = true,
|
|
allow_newline = true,
|
|
cursor_at_end = false,
|
|
-- and one of these:
|
|
add_scroll_buttons = true,
|
|
add_nav_bar = true,
|
|
|
|
If it would take the user more than half a minute to recover from a mistake,
|
|
a "Cancel" button <em>must</em> be added to the dialog. The cancellation button
|
|
should be kept on the left and the button executing the action on the right.
|
|
|
|
It is strongly recommended to use a text describing the action to be
|
|
executed, as demonstrated in the example above. If the resulting phrase would be
|
|
longer than three words it should just read "OK".
|
|
|
|
]]
|
|
|
|
local Blitbuffer = require("ffi/blitbuffer")
|
|
local ButtonTable = require("ui/widget/buttontable")
|
|
local CenterContainer = require("ui/widget/container/centercontainer")
|
|
local Device = require("device")
|
|
local Font = require("ui/font")
|
|
local FrameContainer = require("ui/widget/container/framecontainer")
|
|
local Geom = require("ui/geometry")
|
|
local InputContainer = require("ui/widget/container/inputcontainer")
|
|
local InputText = require("ui/widget/inputtext")
|
|
local LineWidget = require("ui/widget/linewidget")
|
|
local MovableContainer = require("ui/widget/container/movablecontainer")
|
|
local RenderText = require("ui/rendertext")
|
|
local Size = require("ui/size")
|
|
local TextBoxWidget = require("ui/widget/textboxwidget")
|
|
local TextWidget = require("ui/widget/textwidget")
|
|
local UIManager = require("ui/uimanager")
|
|
local VerticalGroup = require("ui/widget/verticalgroup")
|
|
local VerticalSpan = require("ui/widget/verticalspan")
|
|
local Screen = Device.screen
|
|
|
|
local InputDialog = InputContainer:new{
|
|
is_always_active = true,
|
|
title = "",
|
|
input = "",
|
|
input_hint = "",
|
|
description = nil,
|
|
buttons = nil,
|
|
input_type = nil,
|
|
enter_callback = nil,
|
|
allow_newline = false, -- allow entering new lines (this disables any enter_callback)
|
|
cursor_at_end = true, -- starts with cursor at end of text, ready for appending
|
|
fullscreen = false, -- adjust to full screen minus keyboard
|
|
condensed = false, -- true will prevent adding air and balance between elements
|
|
add_scroll_buttons = false, -- add scroll Up/Down buttons to first row of buttons
|
|
add_nav_bar = false, -- append a row of page navigation buttons
|
|
-- note that the text widget can be scrolled with Swipe North/South even when no button
|
|
|
|
-- movable = true, -- set to false if movable gestures conflicts with subwidgets gestures
|
|
-- for now, too much conflicts between InputText and MovableContainer, and
|
|
-- there's the keyboard to exclude from move area (the InputDialog could
|
|
-- be moved under the keyboard, and the user would be locked)
|
|
movable = false,
|
|
|
|
width = nil,
|
|
|
|
text_width = nil,
|
|
text_height = nil,
|
|
|
|
title_face = Font:getFace("x_smalltfont"),
|
|
description_face = Font:getFace("x_smallinfofont"),
|
|
input_face = Font:getFace("x_smallinfofont"),
|
|
|
|
title_padding = Size.padding.default,
|
|
title_margin = Size.margin.title,
|
|
desc_padding = Size.padding.default, -- Use the same as title for their
|
|
desc_margin = Size.margin.title, -- texts to be visually aligned
|
|
input_padding = Size.padding.default,
|
|
input_margin = Size.margin.default,
|
|
button_padding = Size.padding.default,
|
|
border_size = Size.border.window,
|
|
}
|
|
|
|
function InputDialog:init()
|
|
if self.fullscreen then
|
|
self.movable = false
|
|
self.border_size = 0
|
|
self.width = Screen:getWidth() - 2*self.border_size
|
|
else
|
|
self.width = self.width or Screen:getWidth() * 0.8
|
|
end
|
|
if self.condensed then
|
|
self.text_width = self.width - 2*(self.border_size + self.input_padding + self.input_margin)
|
|
else
|
|
self.text_width = self.text_width or self.width * 0.9
|
|
end
|
|
|
|
-- Title & description
|
|
local title_width = RenderText:sizeUtf8Text(0, self.width,
|
|
self.title_face, self.title, true).x
|
|
if title_width > self.width then
|
|
local indicator = " >> "
|
|
local indicator_w = RenderText:sizeUtf8Text(0, self.width,
|
|
self.title_face, indicator, true).x
|
|
self.title = RenderText:getSubTextByWidth(self.title, self.title_face,
|
|
self.width - indicator_w, true) .. indicator
|
|
end
|
|
self.title = FrameContainer:new{
|
|
padding = self.title_padding,
|
|
margin = self.title_margin,
|
|
bordersize = 0,
|
|
TextWidget:new{
|
|
text = self.title,
|
|
face = self.title_face,
|
|
width = self.width,
|
|
}
|
|
}
|
|
self.title_bar = LineWidget:new{
|
|
dimen = Geom:new{
|
|
w = self.width,
|
|
h = Size.line.thick,
|
|
}
|
|
}
|
|
if self.description then
|
|
self.description_widget = FrameContainer:new{
|
|
padding = self.desc_padding,
|
|
margin = self.desc_margin,
|
|
bordersize = 0,
|
|
TextBoxWidget:new{
|
|
text = self.description,
|
|
face = self.description_face,
|
|
width = self.width - 2*self.desc_padding - 2*self.desc_margin,
|
|
}
|
|
}
|
|
else
|
|
self.description_widget = VerticalSpan:new{ width = 0 }
|
|
end
|
|
|
|
-- Vertical spaces added before and after InputText
|
|
-- (these will be adjusted later to center the input text if needed)
|
|
local vspan_before_input_text = VerticalSpan:new{ width = 0 }
|
|
local vspan_after_input_text = VerticalSpan:new{ width = 0 }
|
|
-- We add the same vertical space used under description after the input widget
|
|
-- (can be disabled by setting condensed=true)
|
|
if not self.condensed then
|
|
local desc_pad_height = self.desc_margin + self.desc_padding
|
|
if self.description then
|
|
vspan_before_input_text.width = 0 -- already provided by description_widget
|
|
vspan_after_input_text.width = desc_pad_height
|
|
else
|
|
vspan_before_input_text.width = desc_pad_height
|
|
vspan_after_input_text.width = desc_pad_height
|
|
end
|
|
end
|
|
|
|
-- Buttons
|
|
if self.add_nav_bar then
|
|
if not self.buttons then
|
|
self.buttons = {}
|
|
end
|
|
local nav_bar = {}
|
|
table.insert(self.buttons, nav_bar)
|
|
table.insert(nav_bar, {
|
|
text = "⇱",
|
|
callback = function()
|
|
self._input_widget:scrollToTop()
|
|
end,
|
|
})
|
|
table.insert(nav_bar, {
|
|
text = "⇲",
|
|
callback = function()
|
|
self._input_widget:scrollToBottom()
|
|
end,
|
|
})
|
|
table.insert(nav_bar, {
|
|
text = "△",
|
|
callback = function()
|
|
self._input_widget:scrollUp()
|
|
end,
|
|
})
|
|
table.insert(nav_bar, {
|
|
text = "▽",
|
|
callback = function()
|
|
self._input_widget:scrollDown()
|
|
end,
|
|
})
|
|
elseif self.add_scroll_buttons then
|
|
if not self.buttons then
|
|
self.buttons = {{}}
|
|
end
|
|
-- Add them to the end of first row
|
|
table.insert(self.buttons[1], {
|
|
text = "△",
|
|
callback = function()
|
|
self._input_widget:scrollUp()
|
|
end,
|
|
})
|
|
table.insert(self.buttons[1], {
|
|
text = "▽",
|
|
callback = function()
|
|
self._input_widget:scrollDown()
|
|
end,
|
|
})
|
|
end
|
|
self.button_table = ButtonTable:new{
|
|
width = self.width - 2*self.button_padding,
|
|
button_font_face = "cfont",
|
|
button_font_size = 20,
|
|
buttons = self.buttons,
|
|
zero_sep = true,
|
|
show_parent = self,
|
|
}
|
|
local buttons_container = CenterContainer:new{
|
|
dimen = Geom:new{
|
|
w = self.width,
|
|
h = self.button_table:getSize().h,
|
|
},
|
|
self.button_table,
|
|
}
|
|
|
|
-- InputText
|
|
if not self.text_height or self.fullscreen then
|
|
-- We need to find the best height to avoid screen overflow
|
|
-- Create a dummy input widget to get some metrics
|
|
local input_widget = InputText:new{
|
|
text = self.fullscreen and "-" or self.input,
|
|
face = self.input_face,
|
|
width = self.text_width,
|
|
padding = self.input_padding,
|
|
margin = self.input_margin,
|
|
}
|
|
local text_height = input_widget:getTextHeight()
|
|
local line_height = input_widget:getLineHeight()
|
|
local input_pad_height = input_widget:getSize().h - text_height
|
|
local keyboard_height = input_widget:getKeyboardDimen().h
|
|
input_widget:free()
|
|
-- Find out available height
|
|
local available_height = Screen:getHeight()
|
|
- 2*self.border_size
|
|
- self.title:getSize().h
|
|
- self.title_bar:getSize().h
|
|
- self.description_widget:getSize().h
|
|
- vspan_before_input_text:getSize().h
|
|
- input_pad_height
|
|
- vspan_after_input_text:getSize().h
|
|
- buttons_container:getSize().h
|
|
- keyboard_height
|
|
if self.fullscreen or text_height > available_height then
|
|
-- Don't leave unusable space in the text widget, as the user could think
|
|
-- it's an empty line: move that space in pads after and below (for centering)
|
|
self.text_height = math.floor(available_height / line_height) * line_height
|
|
local pad_height = available_height - self.text_height
|
|
local pad_before = math.ceil(pad_height / 2)
|
|
local pad_after = pad_height - pad_before
|
|
vspan_before_input_text.width = vspan_before_input_text.width + pad_before
|
|
vspan_after_input_text.width = vspan_after_input_text.width + pad_after
|
|
self.cursor_at_end = false -- stay at start if overflowed
|
|
else
|
|
-- Don't leave unusable space in the text widget
|
|
self.text_height = text_height
|
|
end
|
|
end
|
|
self._input_widget = InputText:new{
|
|
text = self.input,
|
|
hint = self.input_hint,
|
|
face = self.input_face,
|
|
width = self.text_width,
|
|
height = self.text_height or nil,
|
|
padding = self.input_padding,
|
|
margin = self.input_margin,
|
|
input_type = self.input_type,
|
|
text_type = self.text_type,
|
|
enter_callback = self.enter_callback or function()
|
|
for _,btn_row in ipairs(self.buttons) do
|
|
for _,btn in ipairs(btn_row) do
|
|
if btn.is_enter_default then
|
|
btn.callback()
|
|
return
|
|
end
|
|
end
|
|
end
|
|
end,
|
|
scroll = true,
|
|
cursor_at_end = self.cursor_at_end,
|
|
parent = self,
|
|
}
|
|
if self.allow_newline then -- remove any enter_callback
|
|
self._input_widget.enter_callback = nil
|
|
end
|
|
if Device:hasKeys() then
|
|
--little hack to piggyback on the layout of the button_table to handle the new InputText
|
|
table.insert(self.button_table.layout, 1, {self._input_widget})
|
|
end
|
|
|
|
-- Final widget
|
|
self.dialog_frame = FrameContainer:new{
|
|
radius = self.fullscreen and 0 or Size.radius.window,
|
|
padding = 0,
|
|
margin = 0,
|
|
bordersize = self.border_size,
|
|
background = Blitbuffer.COLOR_WHITE,
|
|
VerticalGroup:new{
|
|
align = "left",
|
|
self.title,
|
|
self.title_bar,
|
|
self.description_widget,
|
|
vspan_before_input_text,
|
|
CenterContainer:new{
|
|
dimen = Geom:new{
|
|
w = self.width,
|
|
h = self._input_widget:getSize().h,
|
|
},
|
|
self._input_widget,
|
|
},
|
|
vspan_after_input_text,
|
|
buttons_container,
|
|
}
|
|
}
|
|
local frame = self.dialog_frame
|
|
if self.movable then
|
|
frame = MovableContainer:new{
|
|
self.dialog_frame,
|
|
}
|
|
end
|
|
self[1] = CenterContainer:new{
|
|
dimen = Geom:new{
|
|
w = Screen:getWidth(),
|
|
h = Screen:getHeight() - self._input_widget:getKeyboardDimen().h,
|
|
},
|
|
frame
|
|
}
|
|
end
|
|
|
|
function InputDialog:getInputText()
|
|
return self._input_widget:getText()
|
|
end
|
|
|
|
function InputDialog:getInputValue()
|
|
local text = self:getInputText()
|
|
if self.input_type == "number" then
|
|
return tonumber(text)
|
|
else
|
|
return text
|
|
end
|
|
end
|
|
|
|
function InputDialog:setInputText(text)
|
|
self._input_widget:setText(text)
|
|
end
|
|
|
|
function InputDialog:isTextEditable()
|
|
return self._input_widget:isTextEditable()
|
|
end
|
|
|
|
function InputDialog:isTextEdited()
|
|
return self._input_widget:isTextEdited()
|
|
end
|
|
|
|
function InputDialog:onShow()
|
|
UIManager:setDirty(self, function()
|
|
return "ui", self.dialog_frame.dimen
|
|
end)
|
|
end
|
|
|
|
function InputDialog:onCloseWidget()
|
|
self:onClose()
|
|
UIManager:setDirty(nil, function()
|
|
return "partial", self.dialog_frame.dimen
|
|
end)
|
|
end
|
|
|
|
function InputDialog:onShowKeyboard()
|
|
self._input_widget:onShowKeyboard()
|
|
end
|
|
|
|
function InputDialog:onClose()
|
|
self._input_widget:onCloseKeyboard()
|
|
end
|
|
|
|
return InputDialog
|