diff --git a/docs/Lua API.rst b/docs/Lua API.rst index d1dbc2856..9bab6b494 100644 --- a/docs/Lua API.rst +++ b/docs/Lua API.rst @@ -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. diff --git a/docs/changelog.txt b/docs/changelog.txt index 637dd03f0..3afcd3e5c 100644 --- a/docs/changelog.txt +++ b/docs/changelog.txt @@ -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 diff --git a/library/lua/gui/widgets.lua b/library/lua/gui/widgets.lua index ddd5a01d0..d1801e764 100644 --- a/library/lua/gui/widgets.lua +++ b/library/lua/gui/widgets.lua @@ -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() diff --git a/test/library/gui/widgets.EditField.lua b/test/library/gui/widgets.EditField.lua new file mode 100644 index 000000000..15acfddb0 --- /dev/null +++ b/test/library/gui/widgets.EditField.lua @@ -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 diff --git a/test/library/gui/widgets.Label.lua b/test/library/gui/widgets.Label.lua index 49a75a235..6b0097d1e 100644 --- a/test/library/gui/widgets.Label.lua +++ b/test/library/gui/widgets.Label.lua @@ -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 diff --git a/test/library/gui/widgets.lua b/test/library/gui/widgets.lua index 1eed30e4f..95dbd34f1 100644 --- a/test/library/gui/widgets.lua +++ b/test/library/gui/widgets.lua @@ -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