2036 lines
64 KiB
Lua
2036 lines
64 KiB
Lua
-- Simple widgets for screens
|
|
|
|
local _ENV = mkmodule('gui.widgets')
|
|
|
|
local gui = require('gui')
|
|
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 {
|
|
frame_style = DEFAULT_NIL, -- as in gui.FramedScreen
|
|
frame_title = DEFAULT_NIL, -- as in gui.FramedScreen
|
|
on_render = DEFAULT_NIL,
|
|
on_layout = DEFAULT_NIL,
|
|
draggable = false,
|
|
drag_anchors = DEFAULT_NIL,
|
|
drag_bound = 'frame', -- or 'body'
|
|
on_drag_begin = DEFAULT_NIL,
|
|
on_drag_end = DEFAULT_NIL,
|
|
resizable = false,
|
|
resize_anchors = DEFAULT_NIL,
|
|
resize_min = DEFAULT_NIL,
|
|
on_resize_begin = DEFAULT_NIL,
|
|
on_resize_end = DEFAULT_NIL,
|
|
pinnable = false,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
|
|
local function Panel_update_frame(self, frame, clear_state)
|
|
if clear_state then
|
|
self.kbd_get_pos = nil
|
|
self.saved_frame = nil
|
|
self.saved_frame_rect = nil
|
|
self.drag_offset = nil
|
|
self.resize_edge = nil
|
|
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
|
|
|
|
-- 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
|
|
|
|
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
|
|
local requested_pos = mouse_pos.y - parent_rect.y1 - offset.y
|
|
frame.t = math.max(min_pos, math.min(max_height, requested_pos))
|
|
end
|
|
if frame.b or not frame.t then
|
|
local min_pos = bound_rect.y2 - frame_rect.y2
|
|
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))
|
|
end
|
|
if frame.l or not frame.r then
|
|
local min_pos = frame_rect.x1 - bound_rect.x1
|
|
local requested_pos = mouse_pos.x - parent_rect.x1 - offset.x
|
|
frame.l = math.max(min_pos, math.min(max_width, requested_pos))
|
|
end
|
|
if frame.r or not frame.l then
|
|
local min_pos = bound_rect.x2 - frame_rect.x2
|
|
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))
|
|
end
|
|
return frame
|
|
end
|
|
|
|
local function Panel_make_frame(self, mouse_pos)
|
|
mouse_pos = mouse_pos or xy2pos(dfhack.screen.getMousePos())
|
|
return self.resize_edge and Panel_resize_frame(self, mouse_pos)
|
|
or Panel_drag_frame(self, mouse_pos)
|
|
end
|
|
|
|
local function Panel_begin_drag(self, drag_offset, resize_edge)
|
|
Panel_update_frame(self, nil, true)
|
|
self.drag_offset = drag_offset or {x=0, y=0}
|
|
self.resize_edge = resize_edge
|
|
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)
|
|
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
|
|
end
|
|
|
|
local function Panel_end_drag(self, frame, success)
|
|
success = not not success
|
|
if self.prev_focus_owner then
|
|
self.prev_focus_owner:setFocus(true)
|
|
else
|
|
self:setFocus(false)
|
|
end
|
|
Panel_update_frame(self, frame, true)
|
|
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
|
|
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)
|
|
end
|
|
|
|
local function panel_is_on_pin(self)
|
|
local frame_rect = self.frame_rect
|
|
local x,y = dscreen.getMousePos()
|
|
return (x == frame_rect.x2-2 or x == frame_rect.x2-1)
|
|
and (y == frame_rect.y1-1 or y == frame_rect.y1)
|
|
end
|
|
|
|
local function panel_is_pinnable(self)
|
|
return self.pinnable and self.parent_view and self.parent_view.togglePinned
|
|
end
|
|
|
|
function Panel:getMouseFramePos()
|
|
local x,y = Panel.super.getMouseFramePos(self)
|
|
if x then return x, y end
|
|
if panel_is_pinnable(self) and panel_is_on_pin(self) then
|
|
local frame_rect = self.frame_rect
|
|
return frame_rect.width - 3, 0
|
|
end
|
|
end
|
|
|
|
function Panel:onInput(keys)
|
|
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,
|
|
not not keys.SELECT)
|
|
return true
|
|
end
|
|
for code in pairs(keys) do
|
|
local dx, dy = guidm.get_movement_delta(code, 1, 10)
|
|
if dx then
|
|
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
|
|
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_is_pinnable(self) and keys._MOUSE_L_DOWN then
|
|
if panel_is_on_pin(self) then
|
|
self.parent_view:togglePinned()
|
|
return true
|
|
end
|
|
end
|
|
if Panel.super.onInput(self, keys) then
|
|
return true
|
|
end
|
|
if not keys._MOUSE_L_DOWN then return end
|
|
local x,y = self:getMouseFramePos()
|
|
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
|
|
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'
|
|
elseif self.resize_anchors.l and self.resize_anchors.b
|
|
and x == 0 and y == rect.y2 - rect.y1 then
|
|
resize_edge = 'lb'
|
|
elseif self.resize_anchors.r and self.resize_anchors.t
|
|
and x == rect.x2 - rect.x1 and y == 0 then
|
|
resize_edge = 'rt'
|
|
elseif self.resize_anchors.r and self.resize_anchors.t
|
|
and x == 0 and y == 0 then
|
|
resize_edge = 'lt'
|
|
elseif self.resize_anchors.r and x == rect.x2 - rect.x1 then
|
|
resize_edge = 'r'
|
|
elseif self.resize_anchors.l and x == 0 then
|
|
resize_edge = 'l'
|
|
elseif self.resize_anchors.b and y == rect.y2 - rect.y1 then
|
|
resize_edge = 'b'
|
|
elseif self.resize_anchors.t and y == 0 then
|
|
resize_edge = 't'
|
|
end
|
|
end
|
|
|
|
local is_dragging = false
|
|
if not resize_edge and self.draggable then
|
|
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)
|
|
return true
|
|
end
|
|
end
|
|
|
|
function Panel:setKeyboardDragEnabled(enabled)
|
|
if (enabled and self.kbd_get_pos)
|
|
or (not enabled and not self.kbd_get_pos) then
|
|
return
|
|
end
|
|
if enabled then
|
|
local kbd_get_pos = function()
|
|
return {x=self.frame_rect.x1, y=self.frame_rect.y1}
|
|
end
|
|
Panel_begin_drag(self)
|
|
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
|
|
|
|
function Panel:setKeyboardResizeEnabled(enabled)
|
|
if (enabled and self.kbd_get_pos)
|
|
or (not enabled and not self.kbd_get_pos) then
|
|
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
|
|
end
|
|
else
|
|
Panel_end_drag(self)
|
|
end
|
|
end
|
|
|
|
function Panel:onRenderBody(dc)
|
|
if self.on_render then self.on_render(dc) end
|
|
end
|
|
|
|
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
|
|
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
|
|
|
|
function Panel:onRenderFrame(dc, rect)
|
|
Panel.super.onRenderFrame(self, dc, rect)
|
|
if not self.frame_style then return end
|
|
local pinned = nil
|
|
if self.pinnable then
|
|
pinned = self.parent_view and self.parent_view.pinned
|
|
end
|
|
local inactive = self.parent_view and self.parent_view.isOnTop
|
|
and not self.parent_view:isOnTop()
|
|
gui.paint_frame(dc, rect, self.frame_style, self.frame_title,
|
|
self.pinnable, pinned, inactive)
|
|
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
|
|
if self.drag_offset and not self.kbd_get_pos
|
|
and df.global.enabler.mouse_lbut == 0 then
|
|
Panel_end_drag(self, nil, true)
|
|
end
|
|
end
|
|
|
|
------------
|
|
-- Window --
|
|
------------
|
|
|
|
Window = defclass(Window, Panel)
|
|
|
|
Window.ATTRS {
|
|
frame_style = gui.GREY_LINE_FRAME,
|
|
frame_background = gui.CLEAR_PEN,
|
|
frame_inset = 1,
|
|
draggable = true,
|
|
pinnable = 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
|
|
if self.frame_style then
|
|
w = w + 2
|
|
h = h + 2
|
|
end
|
|
if not self.frame then self.frame = {} end
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
|
|
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
|
|
|
|
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()
|
|
self.text_offset = self.subviews[1]:getTextWidth()
|
|
end
|
|
|
|
function EditField:onRenderBody(dc)
|
|
dc:pen(self.text_pen or COLOR_LIGHTCYAN)
|
|
|
|
local cursor_char = '_'
|
|
if not getval(self.active) or not self.focus or gui.blink_visible(300) then
|
|
cursor_char = (self.cursor > #self.text) and ' ' or
|
|
self.text:sub(self.cursor, self.cursor)
|
|
end
|
|
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
|
|
-- 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
|
|
|
|
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
|
|
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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)
|
|
return true
|
|
elseif keys.CUSTOM_CTRL_A then -- home
|
|
self:setCursor(1)
|
|
return true
|
|
elseif keys.KEYBOARD_CURSOR_RIGHT then
|
|
self:setCursor(self.cursor + 1)
|
|
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)
|
|
return true
|
|
elseif keys.CUSTOM_CTRL_E then -- end
|
|
self:setCursor()
|
|
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
|
|
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)
|
|
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
|
|
|
|
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)
|
|
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)
|
|
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
|
|
self.is_dragging = false
|
|
self.scroll_spec = nil
|
|
return
|
|
end
|
|
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
|
|
|
|
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'
|
|
else
|
|
self.is_dragging = y - self.bar_offset
|
|
return true
|
|
end
|
|
self.scroll_spec = scroll_spec
|
|
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
|
|
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
|
|
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
|
|
dc:tile(nil, token.tile)
|
|
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 ''
|
|
|
|
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,
|
|
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)
|
|
end
|
|
|
|
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()
|
|
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
|
|
|
|
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)
|
|
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,
|
|
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
|
|
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
|
|
|
|
function List:postComputeFrame(body)
|
|
self.page_size = math.max(1, math.floor(body.height / self.row_height))
|
|
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 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
|
|
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:tile(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
|
|
|
|
function List:submit()
|
|
if self.on_submit and #self.choices > 0 then
|
|
self.on_submit(self:getSelected())
|
|
return true
|
|
end
|
|
end
|
|
|
|
function List:submit2()
|
|
if self.on_submit2 and #self.choices > 0 then
|
|
self.on_submit2(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
|
|
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)
|
|
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
|
|
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
|