Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scrolling TabBar #5022

Open
wants to merge 5 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/changelog.txt
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ Template for new versions:
## Lua

- ``dfhack.units``: new function ``setPathGoal``
- ``widgets.TabBar``: updated to allow for horizontal scrolling of tabs when there are too many to fit in the available space

## Removed
- UI focus strings for squad panel flows combined into a single tree: ``dwarfmode/SquadEquipment`` -> ``dwarfmode/Squads/Equipment``, ``dwarfmode/SquadSchedule`` -> ``dwarfmode/Squads/Schedule``
Expand Down
35 changes: 32 additions & 3 deletions docs/dev/Lua API.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6194,9 +6194,16 @@ TabBar class
------------

This widget implements a set of one or more tabs to allow navigation between groups
of content. Tabs automatically wrap on the width of the window and will continue
rendering on the next line(s) if all tabs cannot fit on a single line.

of content.

:wrap: If true, tabs automatically wrap on the width of the window and will
continue rendering on the next line(s) if all tabs cannot fit on a single line.
If false, tabs will be truncated and can be scrolled using ``scroll_key``
and ``scroll_key_back``, mouse wheel or by clicking on the scroll labels
that will automatically appear on the left and right sides of the tab bar
as needed. When clicking on a tab or using ``key`` or ``key_back`` to switch tabs,
the selected tab will be scrolled into view if it is not already visible.
Defaults to true.
:key: Specifies a keybinding that can be used to switch to the next tab.
Defaults to ``CUSTOM_CTRL_T``.
:key_back: Specifies a keybinding that can be used to switch to the previous
Expand All @@ -6222,6 +6229,28 @@ rendering on the next line(s) if all tabs cannot fit on a single line.
itself as the second. The default implementation, which will handle most
situations, returns ``self.active_tab_pens``, if ``self.get_cur_page() == idx``,
otherwise returns ``self.inactive_tab_pens``.
:scroll_key: Specifies a keybinding that can be used to scroll the tabs to the right.
Defaults to ``CUSTOM_ALT_T``.
:scroll_key_back: Specifies a keybinding that can be used to scroll the tabs to the left.
Defaults to ``CUSTOM_ALT_Y``.
:scroll_left_text: The text to display on the left scroll label.
Defaults to "<<<".
:scroll_right_text: The text to display on the right scroll label.
Defaults to ">>>".
Comment on lines +6236 to +6239
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a matter of style consistency, I don't think this should be configurable. I think players would expect this to be constant in all situations where it appears.

I also suggest making the horizontal scroll icon look more like this:
image

perhaps with only one left/right triangle if two triangles stacked vertically doesn't fit well. The code for that example screen is here:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah that's a much nicer symbol. Will fix!

:scroll_label_text_pen: The pen to use for the scroll label text.
Defaults to ``Label`` default.
:scroll_label_text_hpen: The pen to use for the scroll label text when hovered.
Defaults to ``scroll_label_text_pen`` with the background
and foreground colors swapped.
:scroll_step: The number of units to scroll tabs by.
Defaults to 10.
:fast_scroll_multiplier: The multiplier for fast scrolling (holding shift).
Defaults to 3.
:scroll_into_view_offset: After a selected tab is scrolled into view, this offset
is added to the scroll position to ensure the tab is
not flush against the edge of the tab bar, allowing
some space for the user to see the next tab.
Defaults to 5.

Tab class
---------
Expand Down
239 changes: 234 additions & 5 deletions library/lua/gui/widgets/tab_bar.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
local Widget = require('gui.widgets.widget')
local ResizingPanel = require('gui.widgets.containers.resizing_panel')
local Label = require('gui.widgets.labels.label')
local Panel = require('gui.widgets.containers.panel')
local utils = require('utils')

local to_pen = dfhack.pen.parse

Expand Down Expand Up @@ -131,6 +134,16 @@ end
---@field get_pens? fun(index: integer, tabbar: self): widgets.TabPens
---@field key string
---@field key_back string
---@field wrap boolean
---@field scroll_step integer
---@field scroll_left_text string
---@field scroll_right_text string
---@field scroll_key string
---@field scroll_key_back string
---@field fast_scroll_modifier integer
---@field scroll_into_view_offset integer
---@field scroll_label_text_pen dfhack.pen
---@field scroll_label_text_hpen dfhack.pen

---@class widgets.TabBar.attrs.partial: widgets.TabBar.attrs

Expand All @@ -151,17 +164,40 @@ TabBar.ATTRS{
get_pens=DEFAULT_NIL,
key='CUSTOM_CTRL_T',
key_back='CUSTOM_CTRL_Y',
wrap = true,
scroll_step = 10,
scroll_left_text = '<<<',
scroll_right_text = '>>>',
scroll_key = 'CUSTOM_ALT_T',
scroll_key_back = 'CUSTOM_ALT_Y',
fast_scroll_modifier = 3,
scroll_into_view_offset = 5,
scroll_label_text_pen = DEFAULT_NIL,
scroll_label_text_hpen = DEFAULT_NIL,
}

---@param self widgets.TabBar
function TabBar:init()
self.scrollable = false
self.scroll_offset = 0
self.first_render = true

local panel = Panel{
view_id='TabBar__tabs',
frame={t=0, l=0, h=2},
frame_inset={l=0},
}

for idx,label in ipairs(self.labels) do
self:addviews{
panel:addviews{
Tab{
frame={t=0, l=0},
id=idx,
label=label,
on_select=self.on_select,
on_select=function()
self.scrollTabIntoView(self, idx)
self.on_select(idx)
end,
get_pens=self.get_pens and function()
return self.get_pens(idx, self)
end or function()
Expand All @@ -174,32 +210,225 @@ function TabBar:init()
}
}
end

self:addviews{panel}

if not self.wrap then
self:addviews{
Label{
view_id='TabBar__scroll_left',
frame={t=0, l=0, w=#self.scroll_left_text},
text_pen=self.scroll_label_text_pen,
text_hpen=self.scroll_label_text_hpen,
text=self.scroll_left_text,
visible = false,
on_click=function()
self:scrollLeft()
end,
},
Label{
view_id='TabBar__scroll_right',
frame={t=0, l=0, w=#self.scroll_right_text},
text_pen=self.scroll_label_text_pen,
text_hpen=self.scroll_label_text_hpen,
text=self.scroll_right_text,
visible = false,
on_click=function()
self:scrollRight()
end,
},
}
end
end

function TabBar:updateScrollElements()
self:showScrollLeft()
self:showScrollRight()
self:updateTabPanelPosition()
end

function TabBar:leftScrollVisible()
return self.scroll_offset < 0
end

function TabBar:showScrollLeft()
if self.wrap then return end
self:scrollLeftElement().visible = self:leftScrollVisible()
end

function TabBar:scrollRightVisible()
return self.scroll_offset > self.offset_to_show_last_tab
end

function TabBar:showScrollRight()
if self.wrap then return end
self:scrollRightElement().visible = self:scrollRightVisible()
end

function TabBar:updateTabPanelPosition()
self:tabsElement().frame_inset.l = self.scroll_offset
self:tabsElement():updateLayout(self.frame_body)
end

function TabBar:tabsElement()
return self.subviews.TabBar__tabs
end

function TabBar:scrollLeftElement()
return self.subviews.TabBar__scroll_left
end

function TabBar:scrollRightElement()
return self.subviews.TabBar__scroll_right
end

function TabBar:scrollTabIntoView(idx)
if self.wrap or not self.scrollable then return end

local tab = self:tabsElement().subviews[idx]
local tab_l = tab.frame.l
local tab_r = tab.frame.l + tab.frame.w
local tabs_l = self:tabsElement().frame.l
local tabs_r = tabs_l + self.frame_body.width
local scroll_offset = self.scroll_offset

if tab_l < tabs_l - scroll_offset then
self.scroll_offset = tabs_l - tab_l + self.scroll_into_view_offset
elseif tab_r > tabs_r - scroll_offset then
self.scroll_offset = self.scroll_offset - (tab_r - tabs_r) - self.scroll_into_view_offset
end

self:capScrollOffset()
self:updateScrollElements()
end

function TabBar:capScrollOffset()
if self.scroll_offset > 0 then
self.scroll_offset = 0
elseif self.scroll_offset < self.offset_to_show_last_tab then
self.scroll_offset = self.offset_to_show_last_tab
end
end

function TabBar:scrollRight(alternate_step)
if not self:scrollRightElement().visible then return end

self.scroll_offset = self.scroll_offset - (alternate_step and alternate_step or self.scroll_step)

self:capScrollOffset()
self:updateScrollElements()
end

function TabBar:scrollLeft(alternate_step)
if not self:scrollLeftElement().visible then return end

self.scroll_offset = self.scroll_offset + (alternate_step and alternate_step or self.scroll_step)

self:capScrollOffset()
self:updateScrollElements()
end

function TabBar:isMouseOver()
for _, sv in ipairs(self:tabsElement().subviews) do
if utils.getval(sv.visible) and sv:getMouseFramePos() then return true end
end
end

function TabBar:postComputeFrame(body)
self.all_tabs_width = 0

local t, l, width = 0, 0, body.width
for _,tab in ipairs(self.subviews) do
self.scrollable = false

self.last_post_compute_width = self.post_compute_width or 0
self.post_compute_width = width

local tab_rows = 1
for _,tab in ipairs(self:tabsElement().subviews) do
tab.visible = true
if l > 0 and l + tab.frame.w > width then
t = t + 2
l = 0
self.scrollable = true
if self.wrap then
t = t + 2
l = 0
tab_rows = tab_rows + 1
end
end
tab.frame.t = t
tab.frame.l = l
l = l + tab.frame.w
self.all_tabs_width = self.all_tabs_width + tab.frame.w
end

self.offset_to_show_last_tab = -(self.all_tabs_width - self.post_compute_width)

if self.scrollable and not self.wrap then
self:scrollRightElement().frame.l = width - #self.scroll_right_text

if self.last_post_compute_width ~= self.post_compute_width then
self.scroll_offset = 0
end
end

if self.first_render then
self.first_render = false
if not self.wrap then
self:scrollTabIntoView(self.get_cur_page())
end
end

-- we have to calculate the height of this based on the number of tab rows we will have
-- so that autoarrange_subviews will work correctly
self:tabsElement().frame.h = tab_rows * 2

self:updateScrollElements()
end

function TabBar:fastStep()
return self.scroll_step * self.fast_scroll_modifier
end

function TabBar:onInput(keys)
if TabBar.super.onInput(self, keys) then return true end
if not self.wrap then
if self:isMouseOver() then
if keys.CONTEXT_SCROLL_UP then
self:scrollLeft()
return true
end
if keys.CONTEXT_SCROLL_DOWN then
self:scrollRight()
return true
end
if keys.CONTEXT_SCROLL_PAGEUP then
self:scrollLeft(self:fastStep())
return true
end
if keys.CONTEXT_SCROLL_PAGEDOWN then
self:scrollRight(self:fastStep())
return true
end
end
if self.scroll_key and keys[self.scroll_key] then
self:scrollRight()
return true
end
if self.scroll_key_back and keys[self.scroll_key_back] then
self:scrollLeft()
return true
end
end
if self.key and keys[self.key] then
local zero_idx = self.get_cur_page() - 1
local next_zero_idx = (zero_idx + 1) % #self.labels
self.scrollTabIntoView(self, next_zero_idx + 1)
self.on_select(next_zero_idx + 1)
return true
end
if self.key_back and keys[self.key_back] then
local zero_idx = self.get_cur_page() - 1
local prev_zero_idx = (zero_idx - 1) % #self.labels
self.scrollTabIntoView(self, prev_zero_idx + 1)
self.on_select(prev_zero_idx + 1)
return true
end
Expand Down