dfhack/library/lua/gui/widgets.lua

2043 lines
64 KiB
Lua

-- Simple widgets for screens
local _ENV = mkmodule('gui.widgets')
local gui = require('gui')
2022-12-12 19:07:05 -07:00
local guidm = require('gui.dwarfmode')
local utils = require('utils')
local dscreen = dfhack.screen
local getval = utils.getval
local to_pen = dfhack.pen.parse
local function show_view(view,vis)
if view then
view.visible = vis
end
end
local function map_opttab(tab,idx)
if tab then
return tab[idx]
else
return idx
end
end
STANDARDSCROLL = {
STANDARDSCROLL_UP = -1,
KEYBOARD_CURSOR_UP = -1,
STANDARDSCROLL_DOWN = 1,
KEYBOARD_CURSOR_DOWN = 1,
STANDARDSCROLL_PAGEUP = '-page',
KEYBOARD_CURSOR_UP_FAST = '-page',
STANDARDSCROLL_PAGEDOWN = '+page',
KEYBOARD_CURSOR_DOWN_FAST = '+page',
}
------------
-- Widget --
------------
Widget = defclass(Widget, gui.View)
Widget.ATTRS {
frame = DEFAULT_NIL,
frame_inset = DEFAULT_NIL,
frame_background = DEFAULT_NIL,
}
function Widget:computeFrame(parent_rect)
local sw, sh = parent_rect.width, parent_rect.height
return gui.compute_frame_body(sw, sh, self.frame, self.frame_inset)
end
function Widget:onRenderFrame(dc, rect)
if self.frame_background then
dc:fill(rect, self.frame_background)
end
end
-----------
-- Panel --
-----------
DOUBLE_CLICK_MS = 500
Panel = defclass(Panel, Widget)
Panel.ATTRS {
2022-11-06 17:42:01 -07:00
frame_style = DEFAULT_NIL, -- as in gui.FramedScreen
frame_title = DEFAULT_NIL, -- as in gui.FramedScreen
on_render = DEFAULT_NIL,
on_layout = DEFAULT_NIL,
2022-12-12 19:07:05 -07:00
draggable = false,
drag_anchors = DEFAULT_NIL,
2022-12-12 19:07:05 -07:00
drag_bound = 'frame', -- or 'body'
on_drag_begin = DEFAULT_NIL,
on_drag_end = DEFAULT_NIL,
2022-12-13 19:15:11 -07:00
resizable = false,
resize_anchors = DEFAULT_NIL,
2022-12-13 19:15:11 -07:00
resize_min = DEFAULT_NIL,
on_resize_begin = DEFAULT_NIL,
on_resize_end = DEFAULT_NIL,
autoarrange_subviews = false, -- whether to automatically lay out subviews
autoarrange_gap = 0, -- how many blank lines to insert between widgets
}
function Panel:init(args)
if not self.drag_anchors then
self.drag_anchors = {title=true, frame=false, body=true}
end
if not self.resize_anchors then
self.resize_anchors = {t=false, l=true, r=true, b=true}
end
2022-12-13 19:15:11 -07:00
self.resize_min = self.resize_min or {}
self.resize_min.w = self.resize_min.w or (self.frame or {}).w or 5
self.resize_min.h = self.resize_min.h or (self.frame or {}).h or 5
self.kbd_get_pos = nil -- fn when we are in keyboard dragging mode
2022-12-12 19:07:05 -07:00
self.saved_frame = nil -- copy of frame when dragging started
self.saved_frame_rect = nil -- copy of frame_rect when dragging started
self.drag_offset = nil -- relative pos of held panel tile
2022-12-13 19:15:11 -07:00
self.resize_edge = nil -- which dimension is being resized?
self.last_title_click_ms = 0 -- used to track double-clicking on the title
self:addviews(args.subviews)
end
2022-12-12 19:07:05 -07:00
local function Panel_update_frame(self, frame, clear_state)
if clear_state then
2022-12-13 19:15:11 -07:00
self.kbd_get_pos = nil
2022-12-12 19:07:05 -07:00
self.saved_frame = nil
self.saved_frame_rect = nil
self.drag_offset = nil
2022-12-13 19:15:11 -07:00
self.resize_edge = nil
2022-12-12 19:07:05 -07:00
end
if not frame then return end
if self.frame.l == frame.l and self.frame.r == frame.r
and self.frame.t == frame.t and self.frame.b == frame.b
and self.frame.w == frame.w and self.frame.h == frame.h then
return
end
self.frame = frame
self:updateLayout()
end
2022-12-13 19:15:11 -07:00
-- dim: the name of the dimension var (i.e. 'h' or 'w')
-- anchor: the name of the anchor var (i.e. 't', 'b', 'l', or 'r')
-- opposite_anchor: the name of the anchor var for the opposite edge
-- max_dim: how big this panel can get from its current pos and fit in parent
-- wanted_dim: how big the player is trying to make the panel
-- max_anchor: max value of the frame anchor for the edge that is being resized
-- wanted_anchor: how small the player is trying to make the anchor value
local function Panel_resize_edge_base(frame, resize_min, dim, anchor,
opposite_anchor, max_dim, wanted_dim,
max_anchor, wanted_anchor)
frame[dim] = math.max(resize_min[dim], math.min(max_dim, wanted_dim))
if frame[anchor] or not frame[opposite_anchor] then
frame[anchor] = math.max(0, math.min(max_anchor, wanted_anchor))
end
end
local function Panel_resize_edge(frame, resize_min, dim, anchor,
opposite_anchor, dim_base, dim_ref, anchor_ref,
dim_far, mouse_ref)
local dim_sign = (anchor == 't' or anchor == 'l') and 1 or -1
local max_dim = dim_base - dim_ref + 1
local wanted_dim = dim_sign * (dim_far - mouse_ref) + 1
local max_anchor = dim_base - resize_min[dim] - dim_ref + 1
local wanted_anchor = dim_sign * (mouse_ref - anchor_ref)
Panel_resize_edge_base(frame, resize_min, dim, anchor, opposite_anchor,
max_dim, wanted_dim, max_anchor, wanted_anchor)
end
local function Panel_resize_frame(self, mouse_pos)
local frame, resize_min = copyall(self.frame), self.resize_min
local parent_rect = self.frame_parent_rect
local ref_rect = self.saved_frame_rect
if self.resize_edge:find('t') then
Panel_resize_edge(frame, resize_min, 'h', 't', 'b', ref_rect.y2,
parent_rect.y1, parent_rect.y1, ref_rect.y2, mouse_pos.y)
end
if self.resize_edge:find('b') then
Panel_resize_edge(frame, resize_min, 'h', 'b', 't', parent_rect.y2,
ref_rect.y1, parent_rect.y2, ref_rect.y1, mouse_pos.y)
end
if self.resize_edge:find('l') then
Panel_resize_edge(frame, resize_min, 'w', 'l', 'r', ref_rect.x2,
parent_rect.x1, parent_rect.x1, ref_rect.x2, mouse_pos.x)
end
if self.resize_edge:find('r') then
Panel_resize_edge(frame, resize_min, 'w', 'r', 'l', parent_rect.x2,
ref_rect.x1, parent_rect.x2, ref_rect.x1, mouse_pos.x)
end
return frame
end
2022-12-12 19:07:05 -07:00
local function Panel_drag_frame(self, mouse_pos)
local frame = copyall(self.frame)
local parent_rect, frame_rect = self.frame_parent_rect, self.frame_rect
local bound_rect = self.drag_bound == 'body' and self.frame_body
or frame_rect
local offset = self.drag_offset
local max_width = parent_rect.width - (bound_rect.x2-frame_rect.x1+1)
local max_height = parent_rect.height - (bound_rect.y2-frame_rect.y1+1)
if frame.t or not frame.b then
local min_pos = frame_rect.y1 - bound_rect.y1
2022-12-13 13:39:17 -07:00
local requested_pos = mouse_pos.y - parent_rect.y1 - offset.y
frame.t = math.max(min_pos, math.min(max_height, requested_pos))
2022-12-12 19:07:05 -07:00
end
if frame.b or not frame.t then
local min_pos = bound_rect.y2 - frame_rect.y2
2022-12-13 13:39:17 -07:00
local requested_pos = parent_rect.y2 - mouse_pos.y + offset.y -
(frame_rect.y2 - frame_rect.y1)
frame.b = math.max(min_pos, math.min(max_height, requested_pos))
2022-12-12 19:07:05 -07:00
end
if frame.l or not frame.r then
local min_pos = frame_rect.x1 - bound_rect.x1
2022-12-13 13:39:17 -07:00
local requested_pos = mouse_pos.x - parent_rect.x1 - offset.x
frame.l = math.max(min_pos, math.min(max_width, requested_pos))
2022-12-12 19:07:05 -07:00
end
if frame.r or not frame.l then
local min_pos = bound_rect.x2 - frame_rect.x2
2022-12-13 13:39:17 -07:00
local requested_pos = parent_rect.x2 - mouse_pos.x + offset.x -
(frame_rect.x2 - frame_rect.x1)
frame.r = math.max(min_pos, math.min(max_width, requested_pos))
2022-12-12 19:07:05 -07:00
end
return frame
end
local function Panel_make_frame(self, mouse_pos)
mouse_pos = mouse_pos or xy2pos(dfhack.screen.getMousePos())
2022-12-13 19:15:11 -07:00
return self.resize_edge and Panel_resize_frame(self, mouse_pos)
or Panel_drag_frame(self, mouse_pos)
2022-12-12 19:07:05 -07:00
end
2022-12-13 19:15:11 -07:00
local function Panel_begin_drag(self, drag_offset, resize_edge)
2022-12-12 19:07:05 -07:00
Panel_update_frame(self, nil, true)
self.drag_offset = drag_offset or {x=0, y=0}
2022-12-13 19:15:11 -07:00
self.resize_edge = resize_edge
2022-12-12 19:07:05 -07:00
self.saved_frame = copyall(self.frame)
self.saved_frame_rect = copyall(self.frame_rect)
self.prev_focus_owner = self.focus_group.cur
self:setFocus(true)
2022-12-13 19:15:11 -07:00
if self.resize_edge then
if self.on_resize_begin then self.on_resize_begin(success) end
else
if self.on_drag_begin then self.on_drag_begin(success) end
end
2022-12-12 19:07:05 -07:00
end
local function Panel_end_drag(self, frame, success)
2022-12-13 19:15:11 -07:00
success = not not success
2022-12-12 19:07:05 -07:00
if self.prev_focus_owner then
self.prev_focus_owner:setFocus(true)
else
self:setFocus(false)
end
Panel_update_frame(self, frame, true)
2022-12-13 19:15:11 -07:00
if self.resize_edge then
if self.on_resize_end then self.on_resize_end(success) end
else
if self.on_drag_end then self.on_drag_end(success) end
end
2022-12-12 19:07:05 -07:00
end
local function Panel_on_double_click(self)
local a = self.resize_anchors
local can_vert, can_horiz = a.t or a.b, a.l or a.r
if not can_vert and not can_horiz then return false end
local f, rmin = self.frame, self.resize_min
local maximized = f.t == 0 and f.b == 0 and f.l == 0 and f.r == 0
local frame
if maximized then
frame = {
t=not can_vert and f.t or nil,
l=not can_horiz and f.l or nil,
b=not can_vert and f.b or nil,
r=not can_horiz and f.r or nil,
w=can_vert and rmin.w or f.w,
h=can_horiz and rmin.h or f.h,
}
else
frame = {
t=can_vert and 0 or f.t,
l=can_horiz and 0 or f.l,
b=can_vert and 0 or f.b,
r=can_horiz and 0 or f.r
}
end
Panel_update_frame(self, frame, true)
2022-12-12 19:07:05 -07:00
end
function Panel:onInput(keys)
2022-12-13 19:15:11 -07:00
if self.kbd_get_pos then
if keys.SELECT or keys.LEAVESCREEN or keys._MOUSE_R_DOWN then
Panel_end_drag(self, not keys.SELECT and self.saved_frame or nil,
2022-12-13 13:39:17 -07:00
not not keys.SELECT)
2022-12-12 19:07:05 -07:00
return true
end
for code in pairs(keys) do
local dx, dy = guidm.get_movement_delta(code, 1, 10)
if dx then
2022-12-13 19:15:11 -07:00
local frame_rect = self.frame_rect
local kbd_pos = self.kbd_get_pos()
kbd_pos.x = kbd_pos.x + dx
kbd_pos.y = kbd_pos.y + dy
2022-12-12 19:07:05 -07:00
Panel_update_frame(self, Panel_make_frame(self, kbd_pos))
return true
end
end
return
end
if self.drag_offset then
if keys._MOUSE_R_DOWN then
Panel_end_drag(self, self.saved_frame)
elseif keys._MOUSE_L then
Panel_update_frame(self, Panel_make_frame(self))
end
return true
end
if Panel.super.onInput(self, keys) then
2022-12-12 19:07:05 -07:00
return true
end
if not keys._MOUSE_L_DOWN then return end
local x,y = self:getMouseFramePos()
2022-12-12 19:07:05 -07:00
if not x then return end
if self.resizable and y == 0 then
local now_ms = dfhack.getTickCount()
if now_ms - self.last_title_click_ms <= DOUBLE_CLICK_MS then
self.last_title_click_ms = 0
if Panel_on_double_click(self) then return true end
else
self.last_title_click_ms = now_ms
end
end
local resize_edge = nil
2022-12-13 19:15:11 -07:00
if self.resizable then
local rect = self.frame_rect
if self.resize_anchors.r and self.resize_anchors.b
and x == rect.x2 - rect.x1 and y == rect.y2 - rect.y1 then
resize_edge = 'rb'
2022-12-13 19:15:11 -07:00
elseif self.resize_anchors.l and self.resize_anchors.b
and x == 0 and y == rect.y2 - rect.y1 then
resize_edge = 'lb'
2022-12-13 19:15:11 -07:00
elseif self.resize_anchors.r and self.resize_anchors.t
and x == rect.x2 - rect.x1 and y == 0 then
resize_edge = 'rt'
2022-12-13 19:15:11 -07:00
elseif self.resize_anchors.r and self.resize_anchors.t
and x == 0 and y == 0 then
resize_edge = 'lt'
2022-12-13 19:15:11 -07:00
elseif self.resize_anchors.r and x == rect.x2 - rect.x1 then
resize_edge = 'r'
2022-12-13 19:15:11 -07:00
elseif self.resize_anchors.l and x == 0 then
resize_edge = 'l'
2022-12-13 19:15:11 -07:00
elseif self.resize_anchors.b and y == rect.y2 - rect.y1 then
resize_edge = 'b'
2022-12-13 19:15:11 -07:00
elseif self.resize_anchors.t and y == 0 then
resize_edge = 't'
2022-12-13 19:15:11 -07:00
end
end
2022-12-12 19:07:05 -07:00
local is_dragging = false
if not resize_edge and self.draggable then
2022-12-12 19:07:05 -07:00
local on_body = self:getMousePos()
is_dragging = (self.drag_anchors.title and self.frame_style and y == 0)
or (self.drag_anchors.frame and not on_body) -- includes inset
or (self.drag_anchors.body and on_body)
end
if resize_edge or is_dragging then
Panel_begin_drag(self, {x=x, y=y}, resize_edge)
2022-12-12 19:07:05 -07:00
return true
end
end
2022-12-13 14:01:49 -07:00
function Panel:setKeyboardDragEnabled(enabled)
2022-12-13 19:15:11 -07:00
if (enabled and self.kbd_get_pos)
or (not enabled and not self.kbd_get_pos) then
2022-12-12 19:07:05 -07:00
return
end
if enabled then
2022-12-30 05:40:50 -07:00
local kbd_get_pos = function()
return {x=self.frame_rect.x1, y=self.frame_rect.y1}
end
Panel_begin_drag(self)
2022-12-13 19:15:11 -07:00
self.kbd_get_pos = kbd_get_pos
else
Panel_end_drag(self)
end
end
local function Panel_get_resize_data(self)
local resize_anchors = self.resize_anchors
if resize_anchors.r and resize_anchors.b then
return 'rb', function()
return {x=self.frame_rect.x2, y=self.frame_rect.y2} end
elseif resize_anchors.l and resize_anchors.b then
return 'lb', function()
return {x=self.frame_rect.x1, y=self.frame_rect.y2} end
elseif resize_anchors.r and resize_anchors.t then
return 'rt', function()
return {x=self.frame_rect.x2, y=self.frame_rect.y1} end
elseif resize_anchors.l and resize_anchors.t then
return 'lt', function()
return {x=self.frame_rect.x1, y=self.frame_rect.y1} end
elseif resize_anchors.b then
return 'b', function()
return {x=(self.frame_rect.x1+self.frame_rect.x2)/2,
y=self.frame_rect.y2} end
elseif resize_anchors.r then
return 'r', function()
return {x=self.frame_rect.x2,
y=(self.frame_rect.y1+self.frame_rect.y2)/2} end
elseif resize_anchors.l then
return 'l', function()
return {x=self.frame_rect.x1,
y=(self.frame_rect.y1+self.frame_rect.y2)/2} end
elseif resize_anchors.t then
return 't', function()
return {x=(self.frame_rect.x1+self.frame_rect.x2)/2,
y=self.frame_rect.y1} end
end
end
2022-12-13 19:15:11 -07:00
function Panel:setKeyboardResizeEnabled(enabled)
if (enabled and self.kbd_get_pos)
or (not enabled and not self.kbd_get_pos) then
2022-12-12 19:07:05 -07:00
return
end
if enabled then
local resize_edge, kbd_get_pos = Panel_get_resize_data(self)
if not resize_edge then
dfhack.printerr('cannot resize window: no anchors are enabled')
else
Panel_begin_drag(self, kbd_get_pos(), resize_edge)
self.kbd_get_pos = kbd_get_pos
2022-12-13 19:15:11 -07:00
end
2022-12-12 19:07:05 -07:00
else
Panel_end_drag(self)
end
end
function Panel:onRenderBody(dc)
if self.on_render then self.on_render(dc) end
end
2022-11-06 17:42:01 -07:00
function Panel:computeFrame(parent_rect)
local sw, sh = parent_rect.width, parent_rect.height
if self.frame then
if self.frame.t and self.frame.h and self.frame.t + self.frame.h > sh then
self.frame.t = math.max(0, sh - self.frame.h)
end
if self.frame.b and self.frame.h and self.frame.b + self.frame.h > sh then
self.frame.b = math.max(0, sh - self.frame.h)
end
if self.frame.l and self.frame.w and self.frame.l + self.frame.w > sw then
self.frame.l = math.max(0, sw - self.frame.w)
end
if self.frame.r and self.frame.w and self.frame.r + self.frame.w > sw then
self.frame.r = math.max(0, sw - self.frame.w)
end
end
2022-11-06 17:42:01 -07:00
return gui.compute_frame_body(sw, sh, self.frame, self.frame_inset,
self.frame_style and 1 or 0)
end
function Panel:postComputeFrame(body)
if self.on_layout then self.on_layout(body) end
end
-- if self.autoarrange_subviews is true, lay out visible subviews vertically,
-- adding gaps between widgets according to self.autoarrange_gap.
function Panel:postUpdateLayout()
-- don't leave artifacts behind on the parent screen when we move
gui.Screen.request_full_screen_refresh = true
if not self.autoarrange_subviews then return end
local gap = self.autoarrange_gap
local y = 0
for _,subview in ipairs(self.subviews) do
if not subview.frame then goto continue end
subview.frame.t = y
if getval(subview.visible) then
y = y + (subview.frame.h or 0) + gap
end
::continue::
end
self.frame_rect.height = y
-- let widgets adjust to their new positions
self:updateSubviewLayout()
end
2022-11-06 17:42:01 -07:00
function Panel:onRenderFrame(dc, rect)
Panel.super.onRenderFrame(self, dc, rect)
if not self.frame_style then return end
local inactive = self.parent_view and self.parent_view.hasFocus
and not self.parent_view:hasFocus()
local pause_forced = self.parent_view and self.parent_view.force_pause
gui.paint_frame(dc, rect, self.frame_style, self.frame_title, inactive,
pause_forced, self.resizable)
if self.kbd_get_pos then
local pos = self.kbd_get_pos()
local pen = to_pen{fg=COLOR_GREEN, bg=COLOR_BLACK}
dc:seek(pos.x, pos.y):pen(pen):char(string.char(0xDB))
end
2022-12-13 19:15:11 -07:00
if self.drag_offset and not self.kbd_get_pos
2022-12-12 19:07:05 -07:00
and df.global.enabler.mouse_lbut == 0 then
Panel_end_drag(self, nil, true)
end
2022-11-06 17:42:01 -07:00
end
------------
-- Window --
------------
Window = defclass(Window, Panel)
Window.ATTRS {
2023-01-23 05:16:38 -07:00
frame_style = gui.WINDOW_FRAME,
frame_background = gui.CLEAR_PEN,
frame_inset = 1,
draggable = true,
}
-------------------
-- ResizingPanel --
-------------------
ResizingPanel = defclass(ResizingPanel, Panel)
ResizingPanel.ATTRS{
auto_height = true,
auto_width = false,
}
-- adjust our frame dimensions according to positions and sizes of our subviews
function ResizingPanel:postUpdateLayout(frame_body)
local w, h = 0, 0
for _,s in ipairs(self.subviews) do
if getval(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
local l,t,r,b = gui.parse_inset(self.frame_inset)
w = w + l + r
h = h + t + b
2022-11-06 17:42:01 -07:00
if self.frame_style then
w = w + 2
h = h + 2
end
if not self.frame then self.frame = {} end
2022-11-06 17:42:01 -07:00
local oldw, oldh = self.frame.w, self.frame.h
if not self.auto_height then h = oldh end
if not self.auto_width then w = oldw end
self.frame.w, self.frame.h = w, h
2022-11-06 17:42:01 -07:00
if not self._updateLayoutGuard and (oldw ~= w or oldh ~= h) then
self._updateLayoutGuard = true -- protect against infinite loops
self:updateLayout() -- our frame has changed, we need to fully refresh
end
self._updateLayoutGuard = nil
end
-----------
-- Pages --
-----------
Pages = defclass(Pages, Panel)
function Pages:init(args)
for _,v in ipairs(self.subviews) do
v.visible = false
end
self:setSelected(args.selected or 1)
end
function Pages:setSelected(idx)
if type(idx) ~= 'number' then
local key = idx
if type(idx) == 'string' then
key = self.subviews[key]
end
idx = utils.linear_index(self.subviews, key)
if not idx then
2022-12-14 18:51:07 -07:00
error('Unknown page: '..tostring(key))
end
end
show_view(self.subviews[self.selected], false)
self.selected = math.min(math.max(1, idx), #self.subviews)
show_view(self.subviews[self.selected], true)
end
function Pages:getSelected()
return self.selected, self.subviews[self.selected]
end
function Pages:getSelectedPage()
return self.subviews[self.selected]
end
----------------
-- Edit field --
----------------
EditField = defclass(EditField, Widget)
EditField.ATTRS{
label_text = DEFAULT_NIL,
text = '',
text_pen = DEFAULT_NIL,
on_char = DEFAULT_NIL,
on_change = DEFAULT_NIL,
on_submit = DEFAULT_NIL,
2022-07-15 14:46:01 -06:00
on_submit2 = DEFAULT_NIL,
key = DEFAULT_NIL,
key_sep = DEFAULT_NIL,
modal = false,
ignore_keys = DEFAULT_NIL,
}
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
2022-07-15 23:22:51 -06:00
self:addviews{HotkeyLabel{frame={t=0,l=0},
key=self.key,
key_sep=self.key_sep,
label=self.label_text,
on_activate=self.key and on_activate or nil}}
end
function EditField:getPreferredFocusState()
return not self.key
end
2022-07-15 23:22:51 -06:00
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
2022-07-15 23:22:51 -06:00
self:setCursor(cursor)
end
function EditField:postUpdateLayout()
self.text_offset = self.subviews[1]:getTextWidth()
end
function EditField:onRenderBody(dc)
dc:pen(self.text_pen or COLOR_LIGHTCYAN)
2022-07-15 23:22:51 -06:00
local cursor_char = '_'
if not getval(self.active) or not self.focus or gui.blink_visible(300) then
2022-07-15 23:22:51 -06:00
cursor_char = (self.cursor > #self.text) and ' ' or
self.text:sub(self.cursor, self.cursor)
end
2022-07-15 23:22:51 -06:00
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
2022-07-15 23:22:51 -06:00
-- 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)
2022-07-15 23:22:51 -06:00
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
2022-07-15 23:22:51 -06:00
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
2023-01-03 13:57:02 -07:00
function EditField:insert(text)
local old = self.text
self:setText(old:sub(1,self.cursor-1)..text..old:sub(self.cursor),
self.cursor + #text)
end
function EditField:onInput(keys)
if not self.focus then
-- only react to our hotkey
return self:inputToSubviews(keys)
end
if self.ignore_keys then
for _,ignore_key in ipairs(self.ignore_keys) do
if keys[ignore_key] then return false end
end
end
2023-01-13 18:05:08 -07:00
if self.key and (keys.LEAVESCREEN or keys._MOUSE_R_DOWN) then
local old = self.text
self:setText(self.saved_text)
if self.on_change and old ~= self.saved_text then
self.on_change(self.text, old)
end
self:setFocus(false)
return true
end
if keys.SELECT or keys.CUSTOM_SHIFT_ENTER then
2022-07-15 14:46:01 -06:00
if self.key then
self:setFocus(false)
end
if keys.CUSTOM_SHIFT_ENTER then
if self.on_submit2 then
self.on_submit2(self.text)
return true
end
else
if self.on_submit then
self.on_submit(self.text)
return true
end
2022-07-15 14:46:01 -06:00
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 - (self.text_offset or 0))
return true
end
elseif keys._STRING then
local old = self.text
if keys._STRING == 0 then
-- handle backspace
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
2023-01-03 13:57:02 -07:00
self:insert(cv)
elseif self.on_char then
return self.modal
end
end
if self.on_change and self.text ~= old then
self.on_change(self.text, old)
end
return true
elseif keys.KEYBOARD_CURSOR_LEFT then
self:setCursor(self.cursor - 1)
2022-07-15 23:22:51 -06:00
return true
elseif keys.CUSTOM_CTRL_B then -- back one word
local _, prev_word_end = self.text:sub(1, self.cursor-1):
find('.*[%w_%-][^%w_%-]')
self:setCursor(prev_word_end or 1)
2022-07-15 23:22:51 -06:00
return true
elseif keys.CUSTOM_CTRL_A then -- home
self:setCursor(1)
2022-07-15 23:22:51 -06:00
return true
elseif keys.KEYBOARD_CURSOR_RIGHT then
self:setCursor(self.cursor + 1)
2022-07-15 23:22:51 -06:00
return true
elseif keys.CUSTOM_CTRL_F then -- forward one word
local _,next_word_start = self.text:find('[^%w_%-][%w_%-]', self.cursor)
self:setCursor(next_word_start)
2022-07-15 23:22:51 -06:00
return true
elseif keys.CUSTOM_CTRL_E then -- end
self:setCursor()
2022-07-15 23:22:51 -06:00
return true
end
-- if we're modal, then unconditionally eat all the input
return self.modal
end
---------------
-- Scrollbar --
---------------
SCROLL_INITIAL_DELAY_MS = 300
SCROLL_DELAY_MS = 20
Scrollbar = defclass(Scrollbar, Widget)
Scrollbar.ATTRS{
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 2
end
function Scrollbar:init()
self.last_scroll_ms = 0
self.is_first_click = false
self.scroll_spec = nil
2022-10-07 17:27:19 -06:00
self.is_dragging = false -- index of the scrollbar tile that we're dragging
self:update(1, 1, 1)
end
local function scrollbar_get_max_pos_and_height(scrollbar)
local frame_body = scrollbar.frame_body
local scrollbar_body_height = (frame_body and frame_body.height or 3) - 2
local height = math.max(1, math.floor(
(math.min(scrollbar.elems_per_page, scrollbar.num_elems) * scrollbar_body_height) /
scrollbar.num_elems))
return scrollbar_body_height - height, height
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
self.top_elem = top_elem
self.elems_per_page, self.num_elems = elems_per_page, num_elems
local max_pos, height = scrollbar_get_max_pos_and_height(self)
2022-10-07 14:14:52 -06:00
local pos = (num_elems == elems_per_page) and 0 or
math.ceil(((top_elem-1) * max_pos) /
(num_elems - elems_per_page))
self.bar_offset, self.bar_height = pos, height
end
2022-10-07 17:27:19 -06:00
local function scrollbar_do_drag(scrollbar)
local _,y = scrollbar.frame_body:localXY(dfhack.screen.getMousePos())
local cur_pos = y - scrollbar.is_dragging
local max_top = scrollbar.num_elems - scrollbar.elems_per_page + 1
local max_pos = scrollbar_get_max_pos_and_height(scrollbar)
local new_top_elem = math.floor(cur_pos * max_top / max_pos) + 1
new_top_elem = math.max(1, math.min(new_top_elem, max_top))
if new_top_elem ~= scrollbar.top_elem then
scrollbar.on_scroll(new_top_elem)
2022-10-07 17:27:19 -06:00
end
end
local function scrollbar_is_visible(scrollbar)
return scrollbar.elems_per_page < scrollbar.num_elems
end
local SCROLLBAR_UP_LEFT_PEN = to_pen{tile=922, ch=47, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_UP_RIGHT_PEN = to_pen{tile=923, ch=92, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_DOWN_LEFT_PEN = to_pen{tile=946, ch=92, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_DOWN_RIGHT_PEN = to_pen{tile=947, ch=47, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_UP_LEFT_PEN = to_pen{tile=930, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_UP_RIGHT_PEN = to_pen{tile=931, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_LEFT_PEN = to_pen{tile=954, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_RIGHT_PEN = to_pen{tile=955, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_UP_LEFT_PEN = to_pen{tile=932, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_UP_RIGHT_PEN = to_pen{tile=933, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_DOWN_LEFT_PEN = to_pen{tile=960, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_DOWN_RIGHT_PEN = to_pen{tile=961, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_LEFT_PEN = to_pen{tile=940, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_RIGHT_PEN = to_pen{tile=941, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_DOWN_LEFT_PEN = to_pen{tile=966, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_DOWN_RIGHT_PEN = to_pen{tile=967, ch=219, fg=COLOR_CYAN, bg=COLOR_BLACK}
local SCROLLBAR_UP_LEFT_HOVER_PEN = to_pen{tile=924, ch=47, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_UP_RIGHT_HOVER_PEN = to_pen{tile=925, ch=92, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_DOWN_LEFT_HOVER_PEN = to_pen{tile=936, ch=92, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_DOWN_RIGHT_HOVER_PEN = to_pen{tile=937, ch=47, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_UP_LEFT_HOVER_PEN = to_pen{tile=930, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_UP_RIGHT_HOVER_PEN = to_pen{tile=931, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_LEFT_HOVER_PEN = to_pen{tile=954, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_RIGHT_HOVER_PEN = to_pen{tile=955, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_UP_LEFT_HOVER_PEN = to_pen{tile=956, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_UP_RIGHT_HOVER_PEN = to_pen{tile=957, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_DOWN_LEFT_HOVER_PEN = to_pen{tile=968, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_DOWN_RIGHT_HOVER_PEN = to_pen{tile=969, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_LEFT_HOVER_PEN = to_pen{tile=942, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_CENTER_RIGHT_HOVER_PEN = to_pen{tile=943, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_DOWN_LEFT_HOVER_PEN = to_pen{tile=966, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_DOWN_RIGHT_HOVER_PEN = to_pen{tile=967, ch=219, fg=COLOR_LIGHTCYAN, bg=COLOR_BLACK}
local SCROLLBAR_BAR_BG_LEFT_PEN = to_pen{tile=934, ch=176, fg=COLOR_DARKGREY, bg=COLOR_BLACK}
local SCROLLBAR_BAR_BG_RIGHT_PEN = to_pen{tile=935, ch=176, fg=COLOR_DARKGREY, bg=COLOR_BLACK}
function Scrollbar:onRenderBody(dc)
-- don't draw if all elements are visible
if not scrollbar_is_visible(self) then
return
end
-- determine which elements should be highlighted
local _,hover_y = self:getMousePos()
local hover_up, hover_down, hover_bar = false, false, false
if hover_y == 0 then
hover_up = true
elseif hover_y == dc.height-1 then
hover_down = true
elseif hover_y then
hover_bar = true
end
-- render up arrow
dc:seek(0, 0)
dc:char(nil, hover_up and SCROLLBAR_UP_LEFT_HOVER_PEN or SCROLLBAR_UP_LEFT_PEN)
dc:char(nil, hover_up and SCROLLBAR_UP_RIGHT_HOVER_PEN or SCROLLBAR_UP_RIGHT_PEN)
-- render scrollbar body
local starty = self.bar_offset + 1
local endy = self.bar_offset + self.bar_height
local midy = (starty + endy)/2
for y=1,dc.height-2 do
dc:seek(0, y)
if y >= starty and y <= endy then
if y == starty and y <= midy - 1 then
dc:char(nil, hover_bar and SCROLLBAR_BAR_UP_LEFT_HOVER_PEN or SCROLLBAR_BAR_UP_LEFT_PEN)
dc:char(nil, hover_bar and SCROLLBAR_BAR_UP_RIGHT_HOVER_PEN or SCROLLBAR_BAR_UP_RIGHT_PEN)
elseif y == midy - 0.5 then
dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_UP_LEFT_HOVER_PEN or SCROLLBAR_BAR_CENTER_UP_LEFT_PEN)
dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_UP_RIGHT_HOVER_PEN or SCROLLBAR_BAR_CENTER_UP_RIGHT_PEN)
elseif y == midy + 0.5 then
dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_DOWN_LEFT_HOVER_PEN or SCROLLBAR_BAR_CENTER_DOWN_LEFT_PEN)
dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_BAR_CENTER_DOWN_RIGHT_PEN)
elseif y == midy then
dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_LEFT_HOVER_PEN or SCROLLBAR_BAR_CENTER_LEFT_PEN)
dc:char(nil, hover_bar and SCROLLBAR_BAR_CENTER_RIGHT_HOVER_PEN or SCROLLBAR_BAR_CENTER_RIGHT_PEN)
elseif y == endy and y >= midy + 1 then
dc:char(nil, hover_bar and SCROLLBAR_BAR_DOWN_LEFT_HOVER_PEN or SCROLLBAR_BAR_DOWN_LEFT_PEN)
dc:char(nil, hover_bar and SCROLLBAR_BAR_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_BAR_DOWN_RIGHT_PEN)
else
dc:char(nil, hover_bar and SCROLLBAR_BAR_LEFT_HOVER_PEN or SCROLLBAR_BAR_LEFT_PEN)
dc:char(nil, hover_bar and SCROLLBAR_BAR_RIGHT_HOVER_PEN or SCROLLBAR_BAR_RIGHT_PEN)
end
else
dc:char(nil, SCROLLBAR_BAR_BG_LEFT_PEN)
dc:char(nil, SCROLLBAR_BAR_BG_RIGHT_PEN)
end
end
-- render down arrow
dc:seek(0, dc.height-1)
dc:char(nil, hover_down and SCROLLBAR_DOWN_LEFT_HOVER_PEN or SCROLLBAR_DOWN_LEFT_PEN)
dc:char(nil, hover_down and SCROLLBAR_DOWN_RIGHT_HOVER_PEN or SCROLLBAR_DOWN_RIGHT_PEN)
2022-10-07 17:27:19 -06:00
if not self.on_scroll then return end
-- manage state for dragging and continuous scrolling
if self.is_dragging then
scrollbar_do_drag(self)
end
if df.global.enabler.mouse_lbut == 0 then
self.last_scroll_ms = 0
2022-10-07 17:27:19 -06:00
self.is_dragging = false
self.scroll_spec = nil
return
end
2022-10-07 17:27:19 -06:00
if self.last_scroll_ms == 0 then return end
local now = dfhack.getTickCount()
local delay = self.is_first_click and
SCROLL_INITIAL_DELAY_MS or SCROLL_DELAY_MS
if now - self.last_scroll_ms >= delay then
self.is_first_click = false
self.on_scroll(self.scroll_spec)
self.last_scroll_ms = now
end
end
function Scrollbar:onInput(keys)
if not self.on_scroll or not scrollbar_is_visible(self) then
return false
end
2022-12-30 06:40:57 -07:00
if self.parent_view:getMousePos() then
if keys.CONTEXT_SCROLL_UP then
self.on_scroll('up_small')
return true
elseif keys.CONTEXT_SCROLL_DOWN then
self.on_scroll('down_small')
return true
elseif keys.CONTEXT_SCROLL_PAGEUP then
self.on_scroll('up_large')
return true
elseif keys.CONTEXT_SCROLL_PAGEDOWN then
self.on_scroll('down_large')
return true
end
end
if not keys._MOUSE_L_DOWN then return false end
local _,y = self:getMousePos()
if not y then return false end
local scroll_spec = nil
if y == 0 then scroll_spec = 'up_small'
elseif y == self.frame_body.height - 1 then scroll_spec = 'down_small'
elseif y <= self.bar_offset then scroll_spec = 'up_large'
elseif y > self.bar_offset + self.bar_height then scroll_spec = 'down_large'
2022-10-07 17:27:19 -06:00
else
self.is_dragging = y - self.bar_offset
return true
end
self.scroll_spec = scroll_spec
2022-10-07 17:27:19 -06:00
self.on_scroll(scroll_spec)
-- reset continuous scroll state
self.is_first_click = true
self.last_scroll_ms = dfhack.getTickCount()
return true
end
-----------
-- Label --
-----------
function parse_label_text(obj)
local text = obj.text or {}
if type(text) ~= 'table' then
text = { text }
end
local curline = nil
local out = { }
local active = nil
local idtab = nil
for _,v in ipairs(text) do
local vv
if type(v) == 'string' then
vv = v:split(NEWLINE)
else
vv = { v }
end
for i = 1,#vv do
local cv = vv[i]
if i > 1 then
if not curline then
table.insert(out, {})
end
curline = nil
end
if cv ~= '' then
if not curline then
curline = {}
table.insert(out, curline)
end
if type(cv) == 'string' then
table.insert(curline, { text = cv })
else
table.insert(curline, cv)
if cv.on_activate then
active = active or {}
table.insert(active, cv)
end
if cv.id then
idtab = idtab or {}
idtab[cv.id] = cv
end
end
end
end
end
obj.text_lines = out
obj.text_active = active
obj.text_ids = idtab
end
local function is_disabled(token)
return (token.disabled ~= nil and getval(token.disabled)) or
(token.enabled ~= nil and not getval(token.enabled))
end
function render_text(obj,dc,x0,y0,pen,dpen,disabled)
local width = 0
2021-01-13 00:27:14 -07:00
for iline = dc and obj.start_line_num or 1, #obj.text_lines do
local x, line = 0, obj.text_lines[iline]
if dc then
local offset = (obj.start_line_num or 1) - 1
2021-01-13 23:02:22 -07:00
local y = y0 + iline - offset - 1
-- skip text outside of the containing frame
if y > dc.height - 1 then break end
dc:seek(x+x0, y)
end
for _,token in ipairs(line) do
token.line = iline
token.x1 = x
if token.gap then
x = x + token.gap
if dc then
dc:advance(token.gap)
end
end
if token.tile then
x = x + 1
if dc then
local tile_pen = tonumber(token.tile) and
to_pen{tile=token.tile} or token.tile
2023-02-01 15:51:46 -07:00
dc:char(nil, tile_pen)
if token.width then
dc:advance(token.width-1)
end
end
end
if token.text or token.key then
local text = ''..(getval(token.text) or '')
local keypen = to_pen(token.key_pen or COLOR_LIGHTGREEN)
if dc then
local tpen = getval(token.pen)
if disabled or is_disabled(token) then
dc:pen(getval(token.dpen) or tpen or dpen)
if keypen.fg ~= COLOR_BLACK then
keypen.bold = false
end
else
dc:pen(tpen or pen)
end
end
local width = getval(token.width)
local padstr
if width then
x = x + width
if #text > width then
text = string.sub(text,1,width)
else
if token.pad_char then
padstr = string.rep(token.pad_char,width-#text)
end
if dc and token.rjustify then
if padstr then dc:string(padstr) else dc:advance(width-#text) end
end
end
else
x = x + #text
end
if token.key then
if type(token.key) == 'string' and not df.interface_key[token.key] then
error('Invalid interface_key: ' .. token.key)
end
local keystr = gui.getKeyDisplay(token.key)
local sep = token.key_sep or ''
2012-10-16 08:33:00 -06:00
x = x + #keystr
if sep:startswith('()') then
if dc then
dc:string(text)
dc:string(' ('):string(keystr,keypen)
dc:string(sep:sub(2))
end
x = x + 1 + #sep
else
if dc then
dc:string(keystr,keypen):string(sep):string(text)
end
x = x + #sep
end
else
if dc then
dc:string(text)
end
end
if width and dc and not token.rjustify then
if padstr then dc:string(padstr) else dc:advance(width-#text) end
end
end
token.x2 = x
end
width = math.max(width, x)
end
obj.text_width = width
end
function check_text_keys(self, keys)
if self.text_active then
for _,item in ipairs(self.text_active) do
if item.key and keys[item.key] and not is_disabled(item) then
item.on_activate()
return true
end
end
end
end
Label = defclass(Label, Widget)
Label.ATTRS{
text_pen = COLOR_WHITE,
text_dpen = COLOR_DARKGREY, -- disabled
text_hpen = DEFAULT_NIL, -- highlight - default is text_pen with reversed brightness
disabled = DEFAULT_NIL,
enabled = DEFAULT_NIL,
auto_height = true,
2012-10-16 08:33:00 -06:00
auto_width = false,
on_click = DEFAULT_NIL,
on_rclick = DEFAULT_NIL,
scroll_keys = STANDARDSCROLL,
}
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)
if not self.text_hpen then
self.text_hpen = ((tonumber(self.text_pen) or tonumber(self.text_pen.fg) or 0) + 8) % 16
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
parse_label_text(self)
if self.auto_height then
self.frame = self.frame or {}
self.frame.h = self:getTextHeight()
end
update_label_scrollbar(self)
add scroll icons to Label widget (#2101) * WIP: add scroll icons to Label widget It's an opt-out. The icons are rendered in the right-most column of the 1st and last row. They are only rendered when text can actually be scrolled in the corresponding direction. WIP: Currently, the icons might overlay text characters, there is no mechanism preventing it * gui.lua: expose the `parse_inset()` function * refactor Label's scroll icon code * since `render_scroll_icons` only works with a label, it's now a class function * `update_scroll_inset` ensures `frame_inset.r` or `.l` is at least 1, according to `show_scroll_icons` * `show_scroll_icons` has 4 possible values: `false` for no icons, `left` for icons on the first column on the left (also ensuring `frame_inset.l >= 1`), `right` - last column on the right, `DEFAULT_NIL` - same as `right` if text height greater than `frame_body.height`, else same as `false`. * make `render_scroll_icons` always draw icons The check now happens in `onRenderFrame` * draw frame's background calling `Label.super.onRenderFrame(self, dc, rect)` makes frame's background invisible for some reason * remove trailing spaces * fix scroll icons placed far above/below text With `Label.frame_inset = 1` the text could be vertically centered with plenty of space below and above, but not all rendered. Before this change, the scroll icons would be at the very top and bottom of the frame instead of near the first and last rendered text line. * always `update_scroll_inset` to react to resized window * draw scroll icons next to text * update `Lua API.rst` with new `Label` parameters * move comment separator up This way every scroll related parameter is in one group * list default values for new parameters in docs * add missing description of `Label.scroll_keys`
2022-04-29 07:55:08 -06:00
end
2012-10-16 08:33:00 -06:00
function Label:preUpdateLayout()
if self.auto_width then
self.frame = self.frame or {}
self.frame.w = self:getTextWidth()
end
end
function Label:postUpdateLayout()
update_label_scrollbar(self)
end
function Label:itemById(id)
if self.text_ids then
return self.text_ids[id]
end
end
function Label:getTextHeight()
return #self.text_lines
end
function Label:getTextWidth()
render_text(self)
return self.text_width
end
function Label:onRenderBody(dc)
local text_pen = self.text_pen
if self:getMousePos() and (self.on_click or self.on_rclick) then
text_pen = self.text_hpen
end
render_text(self,dc,0,0,text_pen,self.text_dpen,is_disabled(self))
end
function Label:on_scrollbar(scroll_spec)
local v = 0
if tonumber(scroll_spec) then
v = scroll_spec - self.start_line_num
elseif 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
self:scroll(v)
end
function Label:scroll(nlines)
if not nlines then return end
local text_height = math.max(1, self:getTextHeight())
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)
elseif nlines == 'home' then
nlines = 1 - self.start_line_num
elseif nlines == 'end' then
nlines = text_height
else
error(('unhandled scroll keyword: "%s"'):format(nlines))
end
end
local n = self.start_line_num + nlines
n = math.min(n, text_height - 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 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] and 0 ~= self:scroll(v) then
return true
end
end
return check_text_keys(self, keys)
end
------------------
-- WrappedLabel --
------------------
WrappedLabel = defclass(WrappedLabel, Label)
WrappedLabel.ATTRS{
text_to_wrap=DEFAULT_NIL,
indent=0,
}
function WrappedLabel:getWrappedText(width)
-- 0 width can happen if the parent has 0 width
if not self.text_to_wrap or width <= 0 then return nil end
local text_to_wrap = getval(self.text_to_wrap)
if type(text_to_wrap) == 'table' then
text_to_wrap = table.concat(text_to_wrap, NEWLINE)
end
return text_to_wrap:wrap(width - self.indent)
end
function WrappedLabel:preUpdateLayout()
self.saved_start_line_num = self.start_line_num
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-3)
if not wrapped_text then return end
local text = {}
for _,line in ipairs(wrapped_text:split(NEWLINE)) do
table.insert(text, {gap=self.indent, text=line})
-- a trailing newline will get ignored so we don't have to manually trim
table.insert(text, NEWLINE)
end
self:setText(text)
self:scroll(self.saved_start_line_num - 1)
end
------------------
-- TooltipLabel --
------------------
TooltipLabel = defclass(TooltipLabel, WrappedLabel)
TooltipLabel.ATTRS{
show_tooltip=DEFAULT_NIL,
indent=2,
text_pen=COLOR_GREY,
}
function TooltipLabel:init()
self.visible = self.show_tooltip
end
-----------------
-- HotkeyLabel --
-----------------
HotkeyLabel = defclass(HotkeyLabel, Label)
HotkeyLabel.ATTRS{
key=DEFAULT_NIL,
key_sep=': ',
label=DEFAULT_NIL,
on_activate=DEFAULT_NIL,
}
function HotkeyLabel:init()
self:setText{{key=self.key, key_sep=self.key_sep, text=self.label,
on_activate=self.on_activate}}
end
function HotkeyLabel:onInput(keys)
if HotkeyLabel.super.onInput(self, keys) then
return true
elseif keys._MOUSE_L_DOWN and self:getMousePos() and self.on_activate then
self.on_activate()
return true
end
end
----------------------
-- CycleHotkeyLabel --
----------------------
CycleHotkeyLabel = defclass(CycleHotkeyLabel, Label)
CycleHotkeyLabel.ATTRS{
key=DEFAULT_NIL,
label=DEFAULT_NIL,
label_width=DEFAULT_NIL,
options=DEFAULT_NIL,
initial_option=1,
on_change=DEFAULT_NIL,
}
function CycleHotkeyLabel:init()
2023-01-07 02:00:40 -07:00
self:setOption(self.initial_option)
self:setText{
{key=self.key, key_sep=': ', text=self.label, width=self.label_width,
on_activate=self:callback('cycle')},
' ',
{text=self:callback('getOptionLabel'),
pen=self:callback('getOptionPen')},
}
end
function CycleHotkeyLabel:cycle()
local old_option_idx = self.option_idx
if self.option_idx == #self.options then
self.option_idx = 1
else
self.option_idx = self.option_idx + 1
end
if self.on_change then
self.on_change(self:getOptionValue(),
self:getOptionValue(old_option_idx))
end
end
2023-01-07 02:00:40 -07:00
function CycleHotkeyLabel:setOption(value_or_index, call_on_change)
local option_idx = nil
for i in ipairs(self.options) do
if value_or_index == self:getOptionValue(i) then
option_idx = i
break
end
end
if not option_idx then
if self.options[value_or_index] then
option_idx = value_or_index
end
end
if not option_idx then
error(('cannot find option with value or index: "%s"')
:format(value_or_index))
end
local old_option_idx = self.option_idx
self.option_idx = option_idx
if call_on_change and self.on_change then
self.on_change(self:getOptionValue(),
self:getOptionValue(old_option_idx))
end
end
local function cyclehotkeylabel_getOptionElem(self, option_idx, key)
option_idx = option_idx or self.option_idx
local option = self.options[option_idx]
if type(option) == 'table' then
return option[key]
end
return option
end
function CycleHotkeyLabel:getOptionLabel(option_idx)
return cyclehotkeylabel_getOptionElem(self, option_idx, 'label')
end
function CycleHotkeyLabel:getOptionValue(option_idx)
return cyclehotkeylabel_getOptionElem(self, option_idx, 'value')
end
function CycleHotkeyLabel:getOptionPen(option_idx)
2023-01-06 13:15:38 -07:00
local pen = cyclehotkeylabel_getOptionElem(self, option_idx, 'pen')
if type(pen) == 'string' then return nil end
return pen
end
function CycleHotkeyLabel:onInput(keys)
if CycleHotkeyLabel.super.onInput(self, keys) then
return true
elseif keys._MOUSE_L_DOWN and self:getMousePos() then
self:cycle()
return true
end
end
-----------------------
-- ToggleHotkeyLabel --
-----------------------
ToggleHotkeyLabel = defclass(ToggleHotkeyLabel, CycleHotkeyLabel)
ToggleHotkeyLabel.ATTRS{
options={{label='On', value=true, pen=COLOR_GREEN},
{label='Off', value=false}},
}
----------
-- List --
----------
List = defclass(List, Widget)
List.ATTRS{
text_pen = COLOR_CYAN,
cursor_pen = COLOR_LIGHTCYAN,
inactive_pen = DEFAULT_NIL,
on_select = DEFAULT_NIL,
on_submit = DEFAULT_NIL,
on_submit2 = DEFAULT_NIL,
2023-02-02 21:24:40 -07:00
on_double_click = DEFAULT_NIL,
on_double_click2 = DEFAULT_NIL,
row_height = 1,
scroll_keys = STANDARDSCROLL,
icon_width = DEFAULT_NIL,
}
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)
else
self.choices = {}
self.selected = 1
end
2023-02-02 21:24:40 -07:00
self.last_select_click_ms = 0 -- used to track double-clicking on an item
end
function List:setChoices(choices, selected)
self.choices = {}
for i,v in ipairs(choices or {}) do
local l = utils.clone(v);
if type(v) ~= 'table' then
l = { text = v }
else
l.text = v.text or v.caption or v[1]
end
parse_label_text(l)
self.choices[i] = l
end
self:setSelected(selected)
end
function List:setSelected(selected)
self.selected = selected or self.selected or 1
self:moveCursor(0, true)
return self.selected
end
function List:getChoices()
return self.choices
end
function List:getSelected()
if #self.choices > 0 then
return self.selected, self.choices[self.selected]
end
end
function List:getContentWidth()
local width = 0
for i,v in ipairs(self.choices) do
render_text(v)
local roww = v.text_width
if v.key then
roww = roww + 3 + #gui.getKeyDisplay(v.key)
end
width = math.max(width, roww)
end
return width + (self.icon_width or 0)
end
function List:getContentHeight()
return #self.choices * self.row_height
end
local function update_list_scrollbar(list)
list.scrollbar:update(list.page_top, list.page_size, #list.choices)
end
function List:postComputeFrame(body)
self.page_size = math.max(1, math.floor(body.height / self.row_height))
update_list_scrollbar(self)
end
function List:postUpdateLayout()
update_list_scrollbar(self)
end
function List:moveCursor(delta, force_cb)
local cnt = #self.choices
2012-10-16 08:33:00 -06:00
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
2012-10-16 08:33:00 -06:00
return
end
local off = self.selected+delta-1
local ds = math.abs(delta)
if ds > 1 then
if off >= cnt+ds-1 then
off = 0
else
off = math.min(cnt-1, off)
end
if off <= -ds then
off = cnt-1
else
off = math.max(0, off)
end
end
local buffer = 1 + math.min(4, math.floor(self.page_size/10))
self.selected = 1 + off % cnt
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 tonumber(scroll_spec) then
v = scroll_spec - self.page_top
elseif 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
local iend = math.min(#choices, top+self.page_size-1)
local iw = self.icon_width
local function paint_icon(icon, obj)
if type(icon) ~= 'string' then
dc:char(nil,icon)
else
if current then
dc:string(icon, obj.icon_pen or self.icon_pen or cur_pen)
else
dc:string(icon, obj.icon_pen or self.icon_pen or cur_dpen)
end
end
end
for i = top,iend do
local obj = choices[i]
local current = (i == self.selected)
local cur_pen = self.cursor_pen
local cur_dpen = self.text_pen
local active_pen = current and cur_pen or cur_dpen
if not getval(self.active) then
cur_pen = self.inactive_pen or self.cursor_pen
end
local y = (i - top)*self.row_height
local icon = getval(obj.icon)
if iw and icon then
dc:seek(0, y):pen(active_pen)
paint_icon(icon, obj)
end
render_text(obj, dc, iw or 0, y, cur_pen, cur_dpen, not current)
local ip = dc.width
if obj.key then
local keystr = gui.getKeyDisplay(obj.key)
ip = ip-3-#keystr
dc:seek(ip,y):pen(self.text_pen)
dc:string('('):string(keystr,COLOR_LIGHTGREEN):string(')')
end
if icon and not iw then
dc:seek(ip-1,y):pen(active_pen)
paint_icon(icon, obj)
end
end
end
function List:getIdxUnderMouse()
if self.scrollbar:getMousePos() then return end
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
return self.page_top + math.floor(mouse_y/self.row_height)
end
end
2012-10-16 08:33:00 -06:00
function List:submit()
if self.on_submit and #self.choices > 0 then
self.on_submit(self:getSelected())
return true
2012-10-16 08:33:00 -06:00
end
end
function List:submit2()
if self.on_submit2 and #self.choices > 0 then
self.on_submit2(self:getSelected())
return true
end
end
2023-02-02 21:24:40 -07:00
function List:double_click()
if #self.choices == 0 then return end
local cb = dfhack.internal.getModifiers().shift and
self.on_double_click2 or self.on_double_click
if cb then
cb(self:getSelected())
return true
end
end
function List:onInput(keys)
if self:inputToSubviews(keys) then
return true
end
if keys.SELECT then
return self:submit()
elseif keys.CUSTOM_SHIFT_ENTER then
return self:submit2()
elseif keys._MOUSE_L_DOWN then
local idx = self:getIdxUnderMouse()
if idx then
2023-02-02 21:24:40 -07:00
local now_ms = dfhack.getTickCount()
if idx ~= self:getSelected() then
self.last_select_click_ms = now_ms
else
if now_ms - self.last_select_click_ms <= DOUBLE_CLICK_MS then
self.last_select_click_ms = 0
if self:double_click() then return true end
else
self.last_select_click_ms = now_ms
end
end
self:setSelected(idx)
if dfhack.internal.getModifiers().shift then
self:submit2()
else
self:submit()
end
return true
end
else
for k,v in pairs(self.scroll_keys) do
if keys[k] then
if v == '+page' then
v = self.page_size
elseif v == '-page' then
v = -self.page_size
end
self:moveCursor(v)
return true
end
end
for i,v in ipairs(self.choices) do
if v.key and keys[v.key] then
self:setSelected(i)
2012-10-16 08:33:00 -06:00
self:submit()
return true
end
end
local current = self.choices[self.selected]
if current then
return check_text_keys(current, keys)
end
end
end
-------------------
-- Filtered List --
-------------------
FilteredList = defclass(FilteredList, Widget)
FilteredList.ATTRS {
edit_below = false,
edit_key = DEFAULT_NIL,
edit_ignore_keys = DEFAULT_NIL,
edit_on_char = DEFAULT_NIL,
}
function FilteredList:init(info)
local on_char = self:callback('onFilterChar')
if self.edit_on_char then
on_char = function(c, text)
return self.edit_on_char(c, text) and self:onFilterChar(c, text)
end
end
self.edit = EditField{
text_pen = info.edit_pen or info.cursor_pen,
frame = { l = info.icon_width, t = 0, h = 1 },
on_change = self:callback('onFilterChange'),
on_char = on_char,
key = self.edit_key,
ignore_keys = self.edit_ignore_keys,
}
self.list = List{
frame = { t = 2 },
text_pen = info.text_pen,
cursor_pen = info.cursor_pen,
inactive_pen = info.inactive_pen,
icon_pen = info.icon_pen,
row_height = info.row_height,
scroll_keys = info.scroll_keys,
icon_width = info.icon_width,
}
if self.edit_below then
self.edit.frame = { l = info.icon_width, b = 0, h = 1 }
self.list.frame = { t = 0, b = 2 }
end
if info.on_select then
self.list.on_select = function()
return info.on_select(self:getSelected())
end
end
if info.on_submit then
self.list.on_submit = function()
return info.on_submit(self:getSelected())
end
end
if info.on_submit2 then
self.list.on_submit2 = function()
return info.on_submit2(self:getSelected())
end
end
2023-02-02 21:42:23 -07:00
if info.on_double_click then
self.list.on_double_click = function()
return info.on_double_click(self:getSelected())
end
end
if info.on_double_click2 then
self.list.on_double_click2 = function()
return info.on_double_click2(self:getSelected())
end
end
self.not_found = Label{
visible = true,
text = info.not_found_label or 'No matches',
text_pen = COLOR_LIGHTRED,
frame = { l = info.icon_width, t = self.list.frame.t },
}
self:addviews{ self.edit, self.list, self.not_found }
if info.choices then
self:setChoices(info.choices, info.selected)
else
self.choices = {}
end
end
function FilteredList:getChoices()
return self.choices
end
function FilteredList:getVisibleChoices()
return self.list.choices
end
function FilteredList:setChoices(choices, pos)
choices = choices or {}
self.edit:setText('')
self.list:setChoices(choices, pos)
self.choices = self.list.choices
self.not_found.visible = (#choices == 0)
end
function FilteredList:submit()
return self.list:submit()
end
function FilteredList:submit2()
return self.list:submit2()
end
function FilteredList:canSubmit()
return not self.not_found.visible
end
function FilteredList:getSelected()
local i,v = self.list:getSelected()
if i then
return map_opttab(self.choice_index, i), v
end
end
function FilteredList:getContentWidth()
return self.list:getContentWidth()
end
function FilteredList:getContentHeight()
return self.list:getContentHeight() + 2
end
function FilteredList:getFilter()
return self.edit.text, self.list.choices
end
function FilteredList:setFilter(filter, pos)
local choices = self.choices
local cidx = nil
filter = filter or ''
if filter ~= self.edit.text then
self.edit:setText(filter)
end
if filter ~= '' then
local tokens = filter:split()
local ipos = pos
choices = {}
cidx = {}
pos = nil
for i,v in ipairs(self.choices) do
local ok = true
local search_key = v.search_key
if not search_key then
if type(v.text) ~= 'table' then
search_key = v.text
else
local texts = {}
for _,token in ipairs(v.text) do
table.insert(texts,
type(token) == 'string' and token
or getval(token.text) or '')
end
search_key = table.concat(texts, ' ')
end
end
for _,key in ipairs(tokens) do
key = key:escape_pattern()
-- start matches at non-space or non-punctuation. this allows
-- punctuation itself to be matched if that is useful (e.g.
-- filenames or parameter names)
if key ~= '' and
not search_key:match('%f[^%p\x00]'..key) and
not search_key:match('%f[^%s\x00]'..key) then
ok = false
break
end
end
if ok then
table.insert(choices, v)
cidx[#choices] = i
if ipos == i then
pos = #choices
end
end
end
end
self.choice_index = cidx
self.list:setChoices(choices, pos)
self.not_found.visible = (#choices == 0)
end
function FilteredList:onFilterChange(text)
self:setFilter(text)
end
function FilteredList:onFilterChar(char, text)
if char == ' ' then
return string.match(text, '%S$')
end
return true
end
return _ENV