Merge pull request #2261 from myk002/myk_widget_mousification

widget usability enhancements
develop
Myk 2022-08-15 16:27:27 -07:00 committed by GitHub
commit 5a22cf9490
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 300 additions and 69 deletions

@ -3880,6 +3880,7 @@ Attributes:
If it returns false, the character is ignored.
:on_change: Change notification callback; used as ``on_change(new_text,old_text)``.
:on_submit: Enter key callback; if set the field will handle the key and call ``on_submit(text)``.
:on_submit2: Shift-Enter key callback; if set the field will handle the key and call ``on_submit2(text)``.
:key: If specified, the field is disabled until this key is pressed. Must be given as a string.
:key_sep: If specified, will be used to customize how the activation key is
displayed. See ``token.key_sep`` in the ``Label`` documentation below.
@ -3901,6 +3902,14 @@ and then call the ``on_submit`` callback. Pressing the Escape key will also
release keyboard focus, but first it will restore the text that was displayed
before the ``EditField`` gained focus and then call the ``on_change`` callback.
The ``EditField`` cursor can be moved to where you want to insert/remove text.
You can click where you want the cursor to move or you can use any of the
following keyboard hotkeys:
- Left/Right arrow: move the cursor one character to the left or right.
- Ctrl-Left/Right arrow: move the cursor one word to the left or right.
- Alt-Left/Right arrow: move the cursor to the beginning/end of the text.
Label class
-----------
@ -3917,11 +3926,14 @@ It has the following attributes:
:auto_width: Sets self.frame.w from the text width.
:on_click: A callback called when the label is clicked (optional)
:on_rclick: A callback called when the label is right-clicked (optional)
:scroll_keys: Specifies which keys the label should react to as a table. Default is ``STANDARDSCROLL`` (up or down arrows, page up or down).
:scroll_keys: Specifies which keys the label should react to as a table. The table should map
keys to the number of lines to scroll as positive or negative integers or one of the keywords
supported by the ``scroll`` method. The default is up/down arrows scrolling by one line and page
up/down scrolling by one page.
:show_scroll_icons: Controls scroll icons' behaviour: ``false`` for no icons, ``'right'`` or ``'left'`` for
icons next to the text in an additional column (``frame_inset`` is adjusted to have ``.r`` or ``.l`` greater than ``0``),
``nil`` same as ``'right'`` but changes ``frame_inset`` only if a scroll icon is actually necessary
(if ``getTextHeight()`` is greater than ``frame_body.height``). Default is ``nil``.
icons next to the text in an additional column (``frame_inset`` is adjusted to have ``.r`` or ``.l`` greater than ``0``),
``nil`` same as ``'right'`` but changes ``frame_inset`` only if a scroll icon is actually necessary
(if ``getTextHeight()`` is greater than ``frame_body.height``). Default is ``nil``.
:up_arrow_icon: The symbol for scroll up arrow. Default is ``string.char(24)`` (``↑``).
:down_arrow_icon: The symbol for scroll down arrow. Default is ``string.char(25)`` (``↓``).
:scroll_icon_pen: Specifies the pen for scroll icons. Default is ``COLOR_LIGHTCYAN``.
@ -4013,6 +4025,12 @@ The Label widget implements the following methods:
Computes the width of the text.
* ``label:scroll(nlines)``
This method takes the number of lines to scroll as positive or negative
integers or one of the following keywords: ``+page``, ``-page``,
``+halfpage``, or ``-halfpage``.
WrappedLabel class
------------------
@ -4055,7 +4073,7 @@ HotkeyLabel class
-----------------
This Label subclass is a convenience class for formatting text that responds to
a hotkey.
a hotkey or mouse click.
It has the following attributes:
@ -4065,13 +4083,13 @@ It has the following attributes:
:label: The string (or a function that returns a string) to display after the
hotkey.
:on_activate: If specified, it is the callback that will be called whenever
the hotkey is pressed.
the hotkey is pressed or the label is clicked.
CycleHotkeyLabel class
----------------------
This Label subclass represents a group of related options that the user can
cycle through by pressing a specified hotkey.
cycle through by pressing a specified hotkey or clicking on the text.
It has the following attributes:
@ -4114,7 +4132,8 @@ This is a specialized subclass of CycleHotkeyLabel that has two options:
List class
----------
The List widget implements a simple list with paging.
The List widget implements a simple list with paging. You can click on a list
item to call the ``on_submit`` callback for that item.
It has the following attributes:
@ -4125,8 +4144,8 @@ It has the following attributes:
:on_select: Selection change callback; called as ``on_select(index,choice)``.
This is also called with *nil* arguments if ``setChoices`` is called
with an empty list.
:on_submit: Enter key callback; if specified, the list reacts to the key
and calls it as ``on_submit(index,choice)``.
:on_submit: Enter key or mouse click callback; if specified, the list reacts to the
key/click and calls the callback as ``on_submit(index,choice)``.
:on_submit2: Shift-Enter key callback; if specified, the list reacts to the key
and calls it as ``on_submit2(index,choice)``.
:row_height: Height of every row in text lines.

@ -52,6 +52,8 @@ changelog.txt uses a syntax similar to RST, with a few special sequences:
- `orders`: added useful library of manager orders. see them with ``orders list`` and import them with, for example, ``orders import library/basic``
- `prospect`: add new ``--show`` option to give the player control over which report sections are shown. e.g. ``prospect all --show ores`` will just show information on ores.
- `seedwatch`: ``seedwatch all`` now adds all plants with seeds to the watchlist, not just the "basic" crops.
- UX: You can now move the cursor around in DFHack text fields in ``gui/`` scripts (e.g. `gui/blueprint`, `gui/quickfort`, or `gui/gm-editor`). You can move the cursor by clicking where you want it to go with the mouse or using the Left/Right arrow keys. Ctrl+Left/Right will move one word at a time, and Alt+Left/Right will move to the beginning/end of the text.
- UX: You can now click on the hotkey hint text in many ``gui/`` script windows to activate the hotkey, like a button. Not all scripts have been updated to use the clickable widget yet, but you can try it in `gui/blueprint` or `gui/quickfort`.
## Documentation
@ -65,6 +67,11 @@ changelog.txt uses a syntax similar to RST, with a few special sequences:
## Lua
- ``tile-material``: fix the order of declarations. The ``GetTileMat`` function now returns the material as intended (always returned nil before). Also changed the license info, with permission of the original author.
- ``widgets.EditField``: new ``onsubmit2`` callback attribute is called when the user hits Shift-Enter.
- ``widgets.EditField``: new function: ``setCursor(position)`` sets the input cursor.
- ``widgets.Label``: ``scroll`` function now interprets the keywords ``+page``, ``-page``, ``+halfpage``, and ``-halfpage`` in addition to simple positive and negative numbers.
- ``widgets.HotkeyLabel``: clicking on the widget will now call ``on_activate()``.
- ``widgets.CycleHotkeyLabel``: clicking on the widget will now cycle the options and trigger ``on_change()``. This also applies to the ``ToggleHotkeyLabel`` subclass.
# 0.47.05-r6

@ -121,12 +121,12 @@ ResizingPanel = defclass(ResizingPanel, Panel)
-- adjust our frame dimensions according to positions and sizes of our subviews
function ResizingPanel:postUpdateLayout(frame_body)
local w, h = 0, 0
for _,subview in ipairs(self.subviews) do
if subview.visible then
w = math.max(w, (subview.frame.l or 0) +
(subview.frame.w or frame_body.width))
h = math.max(h, (subview.frame.t or 0) +
(subview.frame.h or frame_body.height))
for _,s in ipairs(self.subviews) do
if s.visible then
w = math.max(w, (s.frame and s.frame.l or 0) +
(s.frame and s.frame.w or frame_body.width))
h = math.max(h, (s.frame and s.frame.t or 0) +
(s.frame and s.frame.h or frame_body.height))
end
end
if not self.frame then self.frame = {} end
@ -184,18 +184,26 @@ EditField.ATTRS{
on_char = DEFAULT_NIL,
on_change = DEFAULT_NIL,
on_submit = DEFAULT_NIL,
on_submit2 = DEFAULT_NIL,
key = DEFAULT_NIL,
key_sep = DEFAULT_NIL,
frame = {h=1},
modal = false,
}
function EditField:preinit(init_table)
init_table.frame = init_table.frame or {}
init_table.frame.h = init_table.frame.h or 1
end
function EditField:init()
local function on_activate()
self.saved_text = self.text
self:setFocus(true)
end
self.start_pos = 1
self.cursor = #self.text + 1
self:addviews{HotkeyLabel{frame={t=0,l=0},
key=self.key,
key_sep=self.key_sep,
@ -207,8 +215,17 @@ function EditField:getPreferredFocusState()
return not self.key
end
function EditField:setCursor(cursor)
if not cursor or cursor > #self.text then
self.cursor = #self.text + 1
return
end
self.cursor = math.max(1, cursor)
end
function EditField:setText(text, cursor)
self.text = text
self:setCursor(cursor)
end
function EditField:postUpdateLayout()
@ -218,14 +235,31 @@ end
function EditField:onRenderBody(dc)
dc:pen(self.text_pen or COLOR_LIGHTCYAN):fill(0,0,dc.width-1,0)
local cursor = '_'
local cursor_char = '_'
if not self.active or not self.focus or gui.blink_visible(300) then
cursor = ' '
cursor_char = (self.cursor > #self.text) and ' ' or
self.text:sub(self.cursor, self.cursor)
end
local txt = self.text .. cursor
local txt = self.text:sub(1, self.cursor - 1) .. cursor_char ..
self.text:sub(self.cursor + 1)
local max_width = dc.width - self.text_offset
self.start_pos = 1
if #txt > max_width then
txt = string.char(27)..string.sub(txt, #txt-max_width+2)
-- get the substring in the vicinity of the cursor
max_width = max_width - 2
local half_width = math.floor(max_width/2)
local start_pos = math.max(1, self.cursor-half_width)
local end_pos = math.min(#txt, self.cursor+half_width-1)
if self.cursor + half_width > #txt then
start_pos = #txt - (max_width - 1)
end
if self.cursor - half_width <= 1 then
end_pos = max_width + 1
end
self.start_pos = start_pos > 1 and start_pos - 1 or start_pos
txt = ('%s%s%s'):format(start_pos == 1 and '' or string.char(27),
txt:sub(start_pos, end_pos),
end_pos == #txt and '' or string.char(26))
end
dc:advance(self.text_offset):string(txt)
end
@ -238,7 +272,7 @@ function EditField:onInput(keys)
if self.key and keys.LEAVESCREEN then
local old = self.text
self.text = self.saved_text
self:setText(self.saved_text)
if self.on_change and old ~= self.saved_text then
self.on_change(self.text, old)
end
@ -255,17 +289,56 @@ function EditField:onInput(keys)
return true
end
return not not self.key
end
if keys._STRING then
elseif keys.SEC_SELECT then
if self.key then
self:setFocus(false)
end
if self.on_submit2 then
self.on_submit2(self.text)
return true
end
return not not self.key
elseif keys._MOUSE_L then
local mouse_x, mouse_y = self:getMousePos()
if mouse_x then
self:setCursor(self.start_pos + mouse_x)
return true
end
elseif keys.CURSOR_LEFT then
self:setCursor(self.cursor - 1)
return true
elseif keys.A_MOVE_W_DOWN then -- Ctrl-Left (end of prev word)
local _, prev_word_end = self.text:sub(1, self.cursor-1):
find('.*[%w_%-][^%w_%-]')
self:setCursor(prev_word_end or 1)
return true
elseif keys.A_CARE_MOVE_W then -- Alt-Left (home)
self:setCursor(1)
return true
elseif keys.CURSOR_RIGHT then
self:setCursor(self.cursor + 1)
return true
elseif keys.A_MOVE_E_DOWN then -- Ctrl-Right (beginning of next word)
local _,next_word_start = self.text:find('[^%w_%-][%w_%-]', self.cursor)
self:setCursor(next_word_start)
return true
elseif keys.A_CARE_MOVE_E then -- Alt-Right (end)
self:setCursor()
return true
elseif keys._STRING then
local old = self.text
if keys._STRING == 0 then
-- handle backspace
self.text = string.sub(old, 1, #old-1)
local del_pos = self.cursor - 1
if del_pos > 0 then
self:setText(old:sub(1, del_pos-1) .. old:sub(del_pos+1),
del_pos)
end
else
local cv = string.char(keys._STRING)
if not self.on_char or self.on_char(cv, old) then
self.text = old .. cv
self:setText(old:sub(1,self.cursor-1)..cv..old:sub(self.cursor),
self.cursor + 1)
end
end
if self.on_change and self.text ~= old then
@ -473,7 +546,6 @@ Label.ATTRS{
}
function Label:init(args)
self.start_line_num = 1
-- use existing saved text if no explicit text was specified. this avoids
-- overwriting pre-formatted text that subclasses may have already set
self:setText(args.text or self.text)
@ -483,6 +555,7 @@ function Label:init(args)
end
function Label:setText(text)
self.start_line_num = 1
self.text = text
parse_label_text(self)
@ -579,6 +652,19 @@ function Label:onRenderFrame(dc, rect)
end
function Label:scroll(nlines)
if type(nlines) == 'string' then
if nlines == '+page' then
nlines = self.frame_body.height
elseif nlines == '-page' then
nlines = -self.frame_body.height
elseif nlines == '+halfpage' then
nlines = math.ceil(self.frame_body.height/2)
elseif nlines == '-halfpage' then
nlines = -math.ceil(self.frame_body.height/2)
else
error(('unhandled scroll keyword: "%s"'):format(nlines))
end
end
local n = self.start_line_num + nlines
n = math.min(n, self:getTextHeight() - self.frame_body.height + 1)
n = math.max(n, 1)
@ -595,11 +681,6 @@ function Label:onInput(keys)
end
for k,v in pairs(self.scroll_keys) do
if keys[k] then
if v == '+page' then
v = self.frame_body.height
elseif v == '-page' then
v = -self.frame_body.height
end
self:scroll(v)
end
end
@ -675,6 +756,15 @@ function HotkeyLabel:init()
on_activate=self.on_activate}}
end
function HotkeyLabel:onInput(keys)
if HotkeyLabel.super.onInput(self, keys) then
return true
elseif keys._MOUSE_L and self:getMousePos() then
self.on_activate()
return true
end
end
----------------------
-- CycleHotkeyLabel --
----------------------
@ -747,6 +837,15 @@ function CycleHotkeyLabel:getOptionValue(option_idx)
return option
end
function CycleHotkeyLabel:onInput(keys)
if CycleHotkeyLabel.super.onInput(self, keys) then
return true
elseif keys._MOUSE_L and self:getMousePos() then
self:cycle()
return true
end
end
-----------------------
-- ToggleHotkeyLabel --
-----------------------
@ -953,6 +1052,15 @@ function List:onInput(keys)
elseif self.on_submit2 and keys.SEC_SELECT then
self:submit2()
return true
elseif keys._MOUSE_L then
local _, mouse_y = self:getMousePos()
if mouse_y and #self.choices > 0 and
mouse_y < (#self.choices-self.page_top+1) * self.row_height then
local idx = self.page_top + math.floor(mouse_y/self.row_height)
self:setSelected(idx)
self:submit()
return true
end
else
for k,v in pairs(self.scroll_keys) do
if keys[k] then
@ -1054,7 +1162,7 @@ end
function FilteredList:setChoices(choices, pos)
choices = choices or {}
self.edit.text = ''
self.edit:setText('')
self.list:setChoices(choices, pos)
self.choices = self.list.choices
self.not_found.visible = (#choices == 0)
@ -1096,7 +1204,7 @@ function FilteredList:setFilter(filter, pos)
local cidx = nil
filter = filter or ''
self.edit.text = filter
self.edit:setText(filter)
if filter ~= '' then
local tokens = filter:split()

@ -0,0 +1,56 @@
local widgets = require('gui.widgets')
function test.editfield_cursor()
local e = widgets.EditField{}
e:setFocus(true)
expect.eq(1, e.cursor, 'cursor should be after the empty string')
e:onInput{_STRING=string.byte('a')}
expect.eq('a', e.text)
expect.eq(2, e.cursor)
e:setText('one two three')
expect.eq(14, e.cursor, 'cursor should be after the last char')
e:onInput{_STRING=string.byte('s')}
expect.eq('one two threes', e.text)
expect.eq(15, e.cursor)
e:setCursor(4)
e:onInput{_STRING=string.byte('s')}
expect.eq('ones two threes', e.text)
expect.eq(5, e.cursor)
e:onInput{CURSOR_LEFT=true}
expect.eq(4, e.cursor)
e:onInput{CURSOR_RIGHT=true}
expect.eq(5, e.cursor)
e:onInput{A_CARE_MOVE_W=true}
expect.eq(1, e.cursor, 'interpret alt-left as home')
e:onInput{A_MOVE_E_DOWN=true}
expect.eq(6, e.cursor, 'interpret ctrl-right as goto beginning of next word')
e:onInput{A_CARE_MOVE_E=true}
expect.eq(16, e.cursor, 'interpret alt-right as end')
e:onInput{A_MOVE_W_DOWN=true}
expect.eq(9, e.cursor, 'interpret ctrl-left as goto end of previous word')
end
function test.editfield_click()
local e = widgets.EditField{text='word'}
e:setFocus(true)
expect.eq(5, e.cursor)
mock.patch(e, 'getMousePos', mock.func(0), function()
e:onInput{_MOUSE_L=true}
expect.eq(1, e.cursor)
end)
mock.patch(e, 'getMousePos', mock.func(20), function()
e:onInput{_MOUSE_L=true}
expect.eq(5, e.cursor, 'should only seek to end of text')
end)
mock.patch(e, 'getMousePos', mock.func(2), function()
e:onInput{_MOUSE_L=true}
expect.eq(3, e.cursor)
end)
end

@ -1,13 +1,6 @@
-- test -dhack/scripts/devel/tests -twidgets.Label
local gui = require('gui')
local widgets = require('gui.widgets')
local xtest = {} -- use to temporarily disable tests (change `function xtest.somename` to `function xxtest.somename`)
local wait = function(n)
delay(n or 30) -- enable for debugging the tests
end
local fs = defclass(fs, gui.FramedScreen)
fs.ATTRS = {
frame_style = gui.GREY_LINE_FRAME,
@ -18,64 +11,56 @@ fs.ATTRS = {
focus_path = 'test-framed-screen',
}
function test.Label_correct_frame_body_with_scroll_icons()
function test.correct_frame_body_with_scroll_icons()
local t = {}
for i = 1, 12 do
t[#t+1] = tostring(i)
t[#t+1] = NEWLINE
end
function fs:init(args)
function fs:init()
self:addviews{
widgets.Label{
view_id = 'text',
frame_inset = 0,
text = t,
--show_scroll_icons = 'right',
},
}
end
local o = fs{}
--o:show()
--wait()
expect.eq(o.subviews.text.frame_body.width, 9, "Label's frame_body.x2 and .width should be one smaller because of show_scroll_icons.")
--o:dismiss()
end
function test.Label_correct_frame_body_with_few_text_lines()
function test.correct_frame_body_with_few_text_lines()
local t = {}
for i = 1, 10 do
t[#t+1] = tostring(i)
t[#t+1] = NEWLINE
end
function fs:init(args)
function fs:init()
self:addviews{
widgets.Label{
view_id = 'text',
frame_inset = 0,
text = t,
--show_scroll_icons = 'right',
},
}
end
local o = fs{}
--o:show()
--wait()
expect.eq(o.subviews.text.frame_body.width, 10, "Label's frame_body.x2 and .width should not change with show_scroll_icons = false.")
--o:dismiss()
end
function test.Label_correct_frame_body_without_show_scroll_icons()
function test.correct_frame_body_without_show_scroll_icons()
local t = {}
for i = 1, 12 do
t[#t+1] = tostring(i)
t[#t+1] = NEWLINE
end
function fs:init(args)
function fs:init()
self:addviews{
widgets.Label{
view_id = 'text',
@ -87,8 +72,45 @@ function test.Label_correct_frame_body_without_show_scroll_icons()
end
local o = fs{}
--o:show()
--wait()
expect.eq(o.subviews.text.frame_body.width, 10, "Label's frame_body.x2 and .width should not change with show_scroll_icons = false.")
--o:dismiss()
end
function test.scroll()
local t = {}
for i = 1, 12 do
t[#t+1] = tostring(i)
t[#t+1] = NEWLINE
end
function fs:init()
self:addviews{
widgets.Label{
view_id = 'text',
frame_inset = 0,
text = t,
},
}
end
local o = fs{frame_height=3}
local txt = o.subviews.text
expect.eq(1, txt.start_line_num)
txt:scroll(1)
expect.eq(2, txt.start_line_num)
txt:scroll('+page')
expect.eq(5, txt.start_line_num)
txt:scroll('+halfpage')
expect.eq(7, txt.start_line_num)
txt:scroll('-halfpage')
expect.eq(5, txt.start_line_num)
txt:scroll('-page')
expect.eq(2, txt.start_line_num)
txt:scroll(-1)
expect.eq(1, txt.start_line_num)
txt:scroll(-1)
expect.eq(1, txt.start_line_num)
txt:scroll(100)
expect.eq(10, txt.start_line_num)
end

@ -1,18 +1,37 @@
local widgets = require('gui.widgets')
function test.hotkeylabel_click()
local func = mock.func()
local l = widgets.HotkeyLabel{key='SELECT', on_activate=func}
mock.patch(l, 'getMousePos', mock.func(0), function()
l:onInput{_MOUSE_L=true}
expect.eq(1, func.call_count)
end)
end
function test.togglehotkeylabel()
local toggle = widgets.ToggleHotkeyLabel{}
expect.true_(toggle:getOptionValue())
toggle:cycle()
expect.false_(toggle:getOptionValue())
toggle:cycle()
expect.true_(toggle:getOptionValue())
local toggle = widgets.ToggleHotkeyLabel{}
expect.true_(toggle:getOptionValue())
toggle:cycle()
expect.false_(toggle:getOptionValue())
toggle:cycle()
expect.true_(toggle:getOptionValue())
end
function test.togglehotkeylabel_default_value()
local toggle = widgets.ToggleHotkeyLabel{initial_option=2}
expect.false_(toggle:getOptionValue())
local toggle = widgets.ToggleHotkeyLabel{initial_option=2}
expect.false_(toggle:getOptionValue())
toggle = widgets.ToggleHotkeyLabel{initial_option=false}
expect.false_(toggle:getOptionValue())
end
toggle = widgets.ToggleHotkeyLabel{initial_option=false}
expect.false_(toggle:getOptionValue())
function test.togglehotkeylabel_click()
local l = widgets.ToggleHotkeyLabel{}
expect.true_(l:getOptionValue())
mock.patch(l, 'getMousePos', mock.func(0), function()
l:onInput{_MOUSE_L=true}
expect.false_(l:getOptionValue())
end)
end