Merge pull request #2319 from myk002/myk_scrollbar

add widgets.Scrollbar
develop
Myk 2022-10-07 15:01:56 -07:00 committed by GitHub
commit ef64be63e7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 304 additions and 206 deletions

@ -4032,6 +4032,38 @@ following keyboard hotkeys:
- 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.
Scrollbar class
---------------
This Widget subclass implements mouse-interactive scrollbars whose bar sizes
represent the amount of content currently visible in an associated display
widget (like a `Label class`_ or a `List class`_). By default they are styled
like scrollbars used in the vanilla DF help screens, but they are configurable.
Scrollbars have the following attributes:
:fg: Specifies the pen for the scroll icons and the active part of the bar. Default is ``COLOR_LIGHTGREEN``.
:bg: Specifies the pen for the background part of the scrollbar. Default is ``COLOR_CYAN``.
:on_scroll: A callback called when the scrollbar is scrolled. It will be called with a single string parameter with a value of "up_large", "down_large", "up_small", or "down_small".
The Scrollbar widget implements the following methods:
* ``scrollbar:update(top_elem, elems_per_page, num_elems)``
Updates the info about the widget that the scrollbar is paired with.
The ``top_elem`` param is the (one-based) index of the first visible element.
The ``elems_per_page`` param is the maximum number of elements that can be
shown at one time. The ``num_elems`` param is the total number of elements
that the paried widget can scroll through. The scrollbar will adjust its
scrollbar size and position accordingly.
Clicking on the arrows at the top or the bottom of a scrollbar will scroll an
associated widget by a small amount. Clicking on the unfilled portion of the
scrollbar above or below the filled area will scroll by a larger amount in that
direction. The amount of scrolling done in each case in determined by the
associated widget, and after scrolling is complete, the associated widget must
call ``scrollbar:update()`` with updated new display info.
Label class
-----------
@ -4052,17 +4084,6 @@ It has the following attributes:
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_scrollbar: Controls scrollbar display: ``false`` for no scrollbar, ``'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``.
:scrollbar_fg: Specifies the pen for the scroll icons and the active part of the bar. Default is ``COLOR_LIGHTGREEN`` (the same as the native DF help screens).
:scrollbar_bg: Specifies the pen for the background part of the scrollbar. Default is ``COLOR_CYAN`` (the same as the native DF help screens).
If the scrollbar is shown, it will react to mouse clicks on the scrollbar itself.
Clicking on the arrows at the top or the bottom will scroll by one line, and
clicking on the unfilled portion of the scrollbar will scroll by a half page in
that direction.
The text itself is represented as a complex structure, and passed
to the object via the ``text`` argument of the constructor, or via
@ -4155,7 +4176,8 @@ The Label widget implements the following methods:
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``.
``+halfpage``, or ``-halfpage``. It returns the number of lines that were
actually scrolled (negative for scrolling up).
WrappedLabel class
------------------
@ -4283,7 +4305,6 @@ Every list item may be specified either as a string, or as a lua table
with the following fields:
:text: Specifies the label text in the same format as the Label text.
:caption, [1]: Deprecated legacy aliases for **text**.
:text_*: Reserved for internal use.
:key: Specifies a keybinding that acts as a shortcut for the specified item.
:icon: Specifies an icon string, or a pen to paint a single character. May be a callback.

@ -41,12 +41,14 @@ changelog.txt uses a syntax similar to RST, with a few special sequences:
- `ls`: indent tag listings and wrap them in the right column for better readability
- `ls`: new ``--exclude`` option for hiding matched scripts from the output. this can be especially useful for modders who don't want their mod scripts to be included in ``ls`` output.
- `digtype`: new ``-z`` option for digtype to restrict designations to the current z-level and down
- UX: List widgets now have mouse-interactive scrollbars
## Documentation
## API
## Lua
- ``widgets.Scrollbar``: new scrollbar widget that can be paired with an associated scrollable widget. Integrated with ``widgets.Label`` and ``widgets.List``.
# 0.47.05-r7

@ -359,6 +359,97 @@ function EditField:onInput(keys)
return self.modal
end
---------------
-- Scrollbar --
---------------
Scrollbar = defclass(Scrollbar, Widget)
Scrollbar.ATTRS{
fg = COLOR_LIGHTGREEN,
bg = COLOR_CYAN,
on_scroll = DEFAULT_NIL,
}
function Scrollbar:preinit(init_table)
init_table.frame = init_table.frame or {}
init_table.frame.w = init_table.frame.w or 1
end
function Scrollbar:init()
self:update(1, 1, 1)
end
-- calculate and cache the number of tiles of empty space above the top of the
-- scrollbar and the number of tiles the scrollbar should occupy to represent
-- the percentage of text that is on the screen.
-- if elems_per_page or num_elems are not specified, the last values passed to
-- Scrollbar:update() are used.
function Scrollbar:update(top_elem, elems_per_page, num_elems)
if not top_elem then error('must specify index of new top element') end
elems_per_page = elems_per_page or self.elems_per_page
num_elems = num_elems or self.num_elems
local frame_height = self.frame_body and self.frame_body.height or 3
local scrollbar_body_height = frame_height - 2
local height = math.max(1, math.floor(
(math.min(elems_per_page, num_elems) * scrollbar_body_height) /
num_elems))
local max_pos = scrollbar_body_height - height
local pos = (num_elems == elems_per_page) and 0 or
math.ceil(((top_elem-1) * max_pos) /
(num_elems - elems_per_page))
self.top_elem = top_elem
self.elems_per_page, self.num_elems = elems_per_page, num_elems
self.bar_offset, self.bar_height = pos, height
end
local UP_ARROW_CHAR = string.char(24)
local DOWN_ARROW_CHAR = string.char(25)
local NO_ARROW_CHAR = string.char(32)
local BAR_CHAR = string.char(7)
local BAR_BG_CHAR = string.char(179)
function Scrollbar:onRenderBody(dc)
-- don't draw if all elements are visible
if self.elems_per_page >= self.num_elems then return end
-- render up arrow if we're not at the top
dc:seek(0, 0):char(
self.top_elem == 1 and NO_ARROW_CHAR or UP_ARROW_CHAR, self.fg, self.bg)
-- render scrollbar body
local starty = self.bar_offset + 1
local endy = self.bar_offset + self.bar_height
for y=1,dc.height-2 do
dc:seek(0, y)
if y >= starty and y <= endy then
dc:char(BAR_CHAR, self.fg)
else
dc:char(BAR_BG_CHAR, self.bg)
end
end
-- render down arrow if we're not at the bottom
local last_visible_el = self.top_elem + self.elems_per_page - 1
dc:seek(0, dc.height-1):char(
last_visible_el >= self.num_elems and NO_ARROW_CHAR or DOWN_ARROW_CHAR,
self.fg, self.bg)
end
function Scrollbar:onInput(keys)
if not keys._MOUSE_L_DOWN or not self.on_scroll then return false end
local _,y = self:getMousePos()
if not y then return false end
local scroll = nil
if y == 0 then scroll = 'up_small'
elseif y == self.frame_body.height - 1 then scroll = 'down_small'
elseif y <= self.bar_offset then scroll = 'up_large'
elseif y > self.bar_offset + self.bar_height then scroll = 'down_large'
end
if scroll then self.on_scroll(scroll) end
return true
end
-----------
-- Label --
-----------
@ -546,12 +637,15 @@ Label.ATTRS{
on_click = DEFAULT_NIL,
on_rclick = DEFAULT_NIL,
scroll_keys = STANDARDSCROLL,
show_scrollbar = DEFAULT_NIL, -- DEFAULT_NIL, 'right', 'left', false
scrollbar_fg = COLOR_LIGHTGREEN,
scrollbar_bg = COLOR_CYAN
}
function Label:init(args)
self.scrollbar = Scrollbar{
frame={r=0},
on_scroll=self:callback('on_scrollbar')}
self:addviews{self.scrollbar}
-- 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)
@ -560,6 +654,12 @@ function Label:init(args)
end
end
local function update_label_scrollbar(label)
local body_height = label.frame_body and label.frame_body.height or 1
label.scrollbar:update(label.start_line_num, body_height,
label:getTextHeight())
end
function Label:setText(text)
self.start_line_num = 1
self.text = text
@ -569,87 +669,8 @@ function Label:setText(text)
self.frame = self.frame or {}
self.frame.h = self:getTextHeight()
end
end
function Label:update_scroll_inset()
if self.show_scrollbar == nil then
self._show_scrollbar = self:getTextHeight() > self.frame_body.height and 'right' or false
else
self._show_scrollbar = self.show_scrollbar
end
if self._show_scrollbar then
-- here self._show_scrollbar can only be either
-- 'left' or any true value which we interpret as right
local l,t,r,b = gui.parse_inset(self.frame_inset)
if self._show_scrollbar == 'left' and l <= 0 then
l = 1
elseif r <= 0 then
r = 1
end
self.frame_inset = {l=l,t=t,r=r,b=b}
end
end
-- the position is the number of tiles of empty space above the top of the
-- scrollbar, and the height is the number of tiles the scrollbar should occupy
-- to represent the percentage of text that is on the screen.
local function get_scrollbar_pos_and_height(label)
local first_visible_line = label.start_line_num
local text_height = label:getTextHeight()
local last_visible_line = first_visible_line + label.frame_body.height - 1
local scrollbar_body_height = label.frame_body.height - 2
local displayed_lines = last_visible_line - first_visible_line
local height = math.floor(((displayed_lines-1) * scrollbar_body_height) /
text_height)
local max_pos = scrollbar_body_height - height
local pos = math.ceil(((first_visible_line-1) * max_pos) /
(text_height - label.frame_body.height))
return pos, height
end
local UP_ARROW_CHAR = string.char(24)
local DOWN_ARROW_CHAR = string.char(25)
local NO_ARROW_CHAR = string.char(32)
local BAR_CHAR = string.char(7)
local BAR_BG_CHAR = string.char(179)
function Label:render_scrollbar(dc, x, y1, y2)
-- render up arrow if we're not at the top
dc:seek(x, y1):char(
self.start_line_num == 1 and NO_ARROW_CHAR or UP_ARROW_CHAR,
self.scrollbar_fg, self.scrollbar_bg)
-- render scrollbar body
local pos, height = get_scrollbar_pos_and_height(self)
local starty = y1 + pos + 1
local endy = y1 + pos + height
for y=y1+1,y2-1 do
if y >= starty and y <= endy then
dc:seek(x, y):char(BAR_CHAR, self.scrollbar_fg)
else
dc:seek(x, y):char(BAR_BG_CHAR, self.scrollbar_bg)
end
end
-- render down arrow if we're not at the bottom
local last_visible_line = self.start_line_num + self.frame_body.height - 1
dc:seek(x, y2):char(
last_visible_line >= self:getTextHeight() and
NO_ARROW_CHAR or DOWN_ARROW_CHAR,
self.scrollbar_fg, self.scrollbar_bg)
end
function Label:computeFrame(parent_rect)
local frame_rect,body_rect = Label.super.computeFrame(self, parent_rect)
self.frame_rect = frame_rect
self.frame_body = parent_rect:viewport(body_rect or frame_rect)
self:update_scroll_inset() -- frame_body is now set
-- recalc with updated frame_inset
return Label.super.computeFrame(self, parent_rect)
update_label_scrollbar(self)
end
function Label:preUpdateLayout()
@ -659,6 +680,10 @@ function Label:preUpdateLayout()
end
end
function Label:postUpdateLayout()
update_label_scrollbar(self)
end
function Label:itemById(id)
if self.text_ids then
return self.text_ids[id]
@ -682,44 +707,19 @@ function Label:onRenderBody(dc)
render_text(self,dc,0,0,text_pen,self.text_dpen,is_disabled(self))
end
function Label:onRenderFrame(dc, rect)
if self._show_scrollbar then
local x = self._show_scrollbar == 'left'
and self.frame_body.x1-dc.x1-1
or self.frame_body.x2-dc.x1+1
self:render_scrollbar(dc,
x,
self.frame_body.y1-dc.y1,
self.frame_body.y2-dc.y1
)
end
end
function Label:click_scrollbar()
if not self._show_scrollbar then return end
local rect = self.frame_body
local x, y = dscreen.getMousePos()
if self._show_scrollbar == 'left' and x ~= rect.x1-1 or x ~= rect.x2+1 then
return
end
if y < rect.y1 or y > rect.y2 then
return
function Label:on_scrollbar(scroll_spec)
local v = 0
if scroll_spec == 'down_large' then
v = '+halfpage'
elseif scroll_spec == 'up_large' then
v = '-halfpage'
elseif scroll_spec == 'down_small' then
v = 1
elseif scroll_spec == 'up_small' then
v = -1
end
if y == rect.y1 then
return -1
elseif y == rect.y2 then
return 1
else
local pos, height = get_scrollbar_pos_and_height(self)
if y <= rect.y1 + pos then
return '-halfpage'
elseif y > rect.y1 + pos + height then
return '+halfpage'
end
end
return nil
self:scroll(v)
end
function Label:scroll(nlines)
@ -740,24 +740,28 @@ function Label:scroll(nlines)
local n = self.start_line_num + nlines
n = math.min(n, self:getTextHeight() - self.frame_body.height + 1)
n = math.max(n, 1)
nlines = n - self.start_line_num
self.start_line_num = n
update_label_scrollbar(self)
return nlines
end
function Label:onInput(keys)
if is_disabled(self) then return false end
if keys._MOUSE_L_DOWN then
if not self:scroll(self:click_scrollbar()) and
self:getMousePos() and self.on_click then
self:on_click()
if self:inputToSubviews(keys) then
return true
end
if keys._MOUSE_L_DOWN and self:getMousePos() and self.on_click then
self:on_click()
return true
end
if keys._MOUSE_R_DOWN and self:getMousePos() and self.on_rclick then
self:on_rclick()
return true
end
for k,v in pairs(self.scroll_keys) do
if keys[k] then
self:scroll(v)
if keys[k] and 0 ~= self:scroll(v) then
return true
end
end
return check_text_keys(self, keys)
@ -787,7 +791,7 @@ end
-- we can't set the text in init() since we may not yet have a frame that we
-- can get wrapping bounds from.
function WrappedLabel:postComputeFrame()
local wrapped_text = self:getWrappedText(self.frame_body.width)
local wrapped_text = self:getWrappedText(self.frame_body.width-1)
if not wrapped_text then return end
local text = {}
for _,line in ipairs(wrapped_text:split(NEWLINE)) do
@ -953,6 +957,11 @@ List.ATTRS{
function List:init(info)
self.page_top = 1
self.page_size = 1
self.scrollbar = Scrollbar{
frame={r=0},
on_scroll=self:callback('on_scrollbar')}
self:addviews{self.scrollbar}
if info.choices then
self:setChoices(info.choices, info.selected)
@ -1017,13 +1026,21 @@ function List:postComputeFrame(body)
self:moveCursor(0)
end
local function update_list_scrollbar(list)
list.scrollbar:update(list.page_top, list.page_size, #list.choices)
end
function List:postUpdateLayout()
update_list_scrollbar(self)
end
function List:moveCursor(delta, force_cb)
local page = math.max(1, self.page_size)
local cnt = #self.choices
if cnt < 1 then
self.page_top = 1
self.selected = 1
update_list_scrollbar(self)
if force_cb and self.on_select then
self.on_select(nil,nil)
end
@ -1046,14 +1063,40 @@ function List:moveCursor(delta, force_cb)
end
end
local buffer = 1 + math.min(4, math.floor(self.page_size/10))
self.selected = 1 + off % cnt
self.page_top = 1 + page * math.floor((self.selected-1) / page)
if (self.selected - buffer) < self.page_top then
self.page_top = math.max(1, self.selected - buffer)
elseif (self.selected + buffer + 1) > (self.page_top + self.page_size) then
local max_page_top = cnt - self.page_size + 1
self.page_top = math.max(1,
math.min(max_page_top, self.selected - self.page_size + buffer + 1))
end
update_list_scrollbar(self)
if (force_cb or delta ~= 0) and self.on_select then
self.on_select(self:getSelected())
end
end
function List:on_scrollbar(scroll_spec)
local v = 0
if scroll_spec == 'down_large' then
v = math.ceil(self.page_size / 2)
elseif scroll_spec == 'up_large' then
v = -math.ceil(self.page_size / 2)
elseif scroll_spec == 'down_small' then
v = 1
elseif scroll_spec == 'up_small' then
v = -1
end
local max_page_top = math.max(1, #self.choices - self.page_size + 1)
self.page_top = math.max(1, math.min(max_page_top, self.page_top + v))
update_list_scrollbar(self)
end
function List:onRenderBody(dc)
local choices = self.choices
local top = self.page_top
@ -1122,6 +1165,9 @@ function List:submit2()
end
function List:onInput(keys)
if self:inputToSubviews(keys) then
return true
end
if self.on_submit and keys.SELECT then
self:submit()
return true

@ -11,70 +11,6 @@ fs.ATTRS = {
focus_path = 'test-framed-screen',
}
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()
self:addviews{
widgets.Label{
view_id = 'text',
frame_inset = 0,
text = t,
},
}
end
local o = fs{}
expect.eq(o.subviews.text.frame_body.width, 9, "Label's frame_body.x2 and .width should be one smaller because of show_scrollbar.")
end
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()
self:addviews{
widgets.Label{
view_id = 'text',
frame_inset = 0,
text = t,
},
}
end
local o = fs{}
expect.eq(o.subviews.text.frame_body.width, 10, "Label's frame_body.x2 and .width should not change with show_scrollbar = false.")
end
function test.correct_frame_body_without_show_scrollbar()
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,
show_scrollbar = false,
},
}
end
local o = fs{}
expect.eq(o.subviews.text.frame_body.width, 10, "Label's frame_body.x2 and .width should not change with show_scrollbar = false.")
end
function test.scroll()
local t = {}
for i = 1, 12 do

@ -0,0 +1,93 @@
local gui = require('gui')
local widgets = require('gui.widgets')
function test.update()
local s = widgets.Scrollbar{}
s.frame_body = {height=100} -- give us some space to work with
-- initial defaults
expect.eq(1, s.top_elem)
expect.eq(1, s.elems_per_page)
expect.eq(1, s.num_elems)
expect.eq(0, s.bar_offset)
expect.eq(1, s.bar_height)
-- top_elem, elems_per_page, num_elems
s:update(1, 10, 0)
expect.eq(1, s.top_elem)
expect.eq(10, s.elems_per_page)
expect.eq(0, s.num_elems)
expect.eq(0, s.bar_offset)
expect.eq(1, s.bar_height)
-- first 10 of 50 shown
s:update(1, 10, 50)
expect.eq(1, s.top_elem)
expect.eq(10, s.elems_per_page)
expect.eq(50, s.num_elems)
expect.eq(0, s.bar_offset)
expect.eq(19, s.bar_height)
-- bottom 10 of 50 shown
s:update(41, 10, 50)
expect.eq(41, s.top_elem)
expect.eq(10, s.elems_per_page)
expect.eq(50, s.num_elems)
expect.eq(79, s.bar_offset)
expect.eq(19, s.bar_height)
-- ~middle 10 of 50 shown
s:update(23, 10, 50)
expect.eq(23, s.top_elem)
expect.eq(10, s.elems_per_page)
expect.eq(50, s.num_elems)
expect.eq(44, s.bar_offset)
expect.eq(19, s.bar_height)
end
function test.onInput()
local spec = nil
local mock_on_scroll = function(scroll_spec) spec = scroll_spec end
local s = widgets.Scrollbar{on_scroll=mock_on_scroll}
s.frame_body = {height=100} -- give us some space to work with
local y = nil
s.getMousePos = function() return 0, y end
-- put scrollbar somewhere in the middle so we can click above and below it
s:update(23, 10, 50)
expect.false_(s:onInput{}, 'no mouse down')
expect.false_(s:onInput{_MOUSE_L_DOWN=true}, 'no y coord')
spec, y = nil, 0
expect.true_(s:onInput{_MOUSE_L_DOWN=true})
expect.eq('up_small', spec, 'on up arrow')
spec, y = nil, 1
expect.true_(s:onInput{_MOUSE_L_DOWN=true})
expect.eq('up_large', spec, 'on body above bar')
spec, y = nil, 44
expect.true_(s:onInput{_MOUSE_L_DOWN=true})
expect.eq('up_large', spec, 'on body just above bar')
spec, y = nil, 45
expect.true_(s:onInput{_MOUSE_L_DOWN=true})
expect.nil_(spec, 'on top of bar')
spec, y = nil, 63
expect.true_(s:onInput{_MOUSE_L_DOWN=true})
expect.nil_(spec, 'on bottom of bar')
spec, y = nil, 64
expect.true_(s:onInput{_MOUSE_L_DOWN=true})
expect.eq('down_large', spec, 'on body just below bar')
spec, y = nil, 98
expect.true_(s:onInput{_MOUSE_L_DOWN=true})
expect.eq('down_large', spec, 'on body below bar')
spec, y = nil, 99
expect.true_(s:onInput{_MOUSE_L_DOWN=true})
expect.eq('down_small', spec, 'on down arrow')
end