Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- -- Simple widgets for screens
- local _ENV = mkmodule('gui.widgets')
- local gui = require('gui')
- local utils = require('utils')
- local dscreen = dfhack.screen
- local function show_view(view,vis)
- if view then
- view.visible = vis
- end
- end
- local function getval(obj)
- if type(obj) == 'function' then
- return obj()
- else
- return obj
- end
- end
- local function map_opttab(tab,idx)
- if tab then
- return tab[idx]
- else
- return idx
- end
- end
- STANDARDSCROLL = {
- STANDARDSCROLL_UP = -1,
- STANDARDSCROLL_DOWN = 1,
- STANDARDSCROLL_PAGEUP = '-page',
- STANDARDSCROLL_PAGEDOWN = '+page',
- }
- SECONDSCROLL = {
- SECONDSCROLL_UP = -1,
- SECONDSCROLL_DOWN = 1,
- SECONDSCROLL_PAGEUP = '-page',
- SECONDSCROLL_PAGEDOWN = '+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 --
- -----------
- Panel = defclass(Panel, Widget)
- Panel.ATTRS {
- on_render = DEFAULT_NIL,
- on_layout = 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)
- self:addviews(args.subviews)
- end
- function Panel:onRenderBody(dc)
- if self.on_render then self.on_render(dc) end
- 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()
- 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 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
- -------------------
- -- ResizingPanel --
- -------------------
- ResizingPanel = defclass(ResizingPanel, Panel)
- -- adjust our frame dimensions according to positions and sizes of our subviews
- function ResizingPanel:postUpdateLayout(frame_body)
- local w, h = 0, 0
- for _,s in ipairs(self.subviews) do
- if s.visible then
- w = math.max(w, (s.frame and s.frame.l or 0) +
- (s.frame and s.frame.w or frame_body.width))
- h = math.max(h, (s.frame and s.frame.t or 0) +
- (s.frame and s.frame.h or frame_body.height))
- end
- end
- if not self.frame then self.frame = {} end
- self.frame.w, self.frame.h = w, h
- 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: '..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):fill(0,0,dc.width-1,0)
- local cursor_char = '_'
- if not 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)
- dc:string((' '):rep(dc.clip_x2 - dc.x))
- 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 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 then
- if self.key then
- self:setFocus(false)
- end
- if self.on_submit then
- self.on_submit(self.text)
- return true
- end
- return not not self.key
- elseif keys.SEC_SELECT then
- if self.key then
- self:setFocus(false)
- end
- if self.on_submit2 then
- self.on_submit2(self.text)
- return true
- end
- return not not self.key
- elseif keys._MOUSE_L then
- local mouse_x, mouse_y = self:getMousePos()
- if mouse_x then
- self:setCursor(self.start_pos + mouse_x)
- return true
- end
- elseif keys._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:setText(old:sub(1,self.cursor-1)..cv..old:sub(self.cursor),
- self.cursor + 1)
- end
- end
- if self.on_change and self.text ~= old then
- self.on_change(self.text, old)
- end
- return true
- elseif keys.CURSOR_LEFT then
- self:setCursor(self.cursor - 1)
- return true
- elseif keys.A_MOVE_W_DOWN then -- Ctrl-Left (end of prev word)
- local _, prev_word_end = self.text:sub(1, self.cursor-1):
- find('.*[%w_%-][^%w_%-]')
- self:setCursor(prev_word_end or 1)
- return true
- elseif keys.A_CARE_MOVE_W then -- Alt-Left (home)
- self:setCursor(1)
- return true
- elseif keys.CURSOR_RIGHT then
- self:setCursor(self.cursor + 1)
- return true
- elseif keys.A_MOVE_E_DOWN then -- Ctrl-Right (beginning of next word)
- local _,next_word_start = self.text:find('[^%w_%-][%w_%-]', self.cursor)
- self:setCursor(next_word_start)
- return true
- elseif keys.A_CARE_MOVE_E then -- Alt-Right (end)
- self:setCursor()
- return true
- end
- -- if we're modal, then unconditionally eat all the input
- return self.modal
- end
- ---------------
- -- Scrollbar --
- ---------------
- -- these can be overridden by the user, e.g.:
- -- require('gui.widgets').SCROLL_DELAY_MS = 100
- SCROLL_INITIAL_DELAY_MS = 300
- SCROLL_DELAY_MS = 20
- Scrollbar = defclass(Scrollbar, Widget)
- Scrollbar.ATTRS{
- fg = COLOR_LIGHTGREEN,
- bg = COLOR_CYAN,
- on_scroll = DEFAULT_NIL,
- }
- function Scrollbar:preinit(init_table)
- init_table.frame = init_table.frame or {}
- init_table.frame.w = init_table.frame.w or 1
- end
- function Scrollbar:init()
- self.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
- -- calculate and cache the number of tiles of empty space above the top of the
- -- scrollbar and the number of tiles the scrollbar should occupy to represent
- -- the percentage of text that is on the screen.
- -- if elems_per_page or num_elems are not specified, the last values passed to
- -- Scrollbar:update() are used.
- function Scrollbar:update(top_elem, elems_per_page, num_elems)
- if not top_elem then error('must specify index of new top element') end
- elems_per_page = elems_per_page or self.elems_per_page
- num_elems = num_elems or self.num_elems
- local frame_height = self.frame_body and self.frame_body.height or 3
- local scrollbar_body_height = frame_height - 2
- local height = math.max(1, math.floor(
- (math.min(elems_per_page, num_elems) * scrollbar_body_height) /
- num_elems))
- local max_pos = scrollbar_body_height - height
- local pos = (num_elems == elems_per_page) and 0 or
- math.ceil(((top_elem-1) * max_pos) /
- (num_elems - elems_per_page))
- self.top_elem = top_elem
- self.elems_per_page, self.num_elems = elems_per_page, num_elems
- self.bar_offset, self.bar_height = pos, height
- end
- local function scrollbar_do_drag(scrollbar)
- local x,y = dfhack.screen.getMousePos()
- x,y = scrollbar.frame_body:localXY(x,y)
- local bar_idx = y - scrollbar.bar_offset
- local delta = bar_idx - scrollbar.is_dragging
- if delta < -scrollbar.bar_height then
- scrollbar.on_scroll('up_large')
- elseif delta < 0 then
- scrollbar.on_scroll('up_small')
- elseif delta > scrollbar.bar_height then
- scrollbar.on_scroll('down_large')
- elseif delta > 0 then
- scrollbar.on_scroll('down_small')
- end
- end
- local UP_ARROW_CHAR = string.char(24)
- local DOWN_ARROW_CHAR = string.char(25)
- local NO_ARROW_CHAR = string.char(32)
- local BAR_CHAR = string.char(7)
- local BAR_BG_CHAR = string.char(179)
- function Scrollbar:onRenderBody(dc)
- -- don't draw if all elements are visible
- if self.elems_per_page >= self.num_elems then return end
- -- render up arrow if we're not at the top
- dc:seek(0, 0):char(
- self.top_elem == 1 and NO_ARROW_CHAR or UP_ARROW_CHAR, self.fg, self.bg)
- -- render scrollbar body
- local starty = self.bar_offset + 1
- local endy = self.bar_offset + self.bar_height
- for y=1,dc.height-2 do
- dc:seek(0, y)
- if y >= starty and y <= endy then
- dc:char(BAR_CHAR, self.fg)
- else
- dc:char(BAR_BG_CHAR, self.bg)
- end
- end
- -- render down arrow if we're not at the bottom
- local last_visible_el = self.top_elem + self.elems_per_page - 1
- dc:seek(0, dc.height-1):char(
- last_visible_el >= self.num_elems and NO_ARROW_CHAR or DOWN_ARROW_CHAR,
- self.fg, self.bg)
- 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_down == 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 keys._MOUSE_L_DOWN or not self.on_scroll then return false end
- local _,y = self:getMousePos()
- if not y then return false end
- local scroll_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:char(nil, token.tile)
- end
- end
- if token.text or token.key then
- local text = ''..(getval(token.text) or '')
- local keypen = dfhack.pen.parse(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
- 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 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
- if type(nlines) == 'string' then
- if nlines == '+page' then
- nlines = self.frame_body.height
- elseif nlines == '-page' then
- nlines = -self.frame_body.height
- elseif nlines == '+halfpage' then
- nlines = math.ceil(self.frame_body.height/2)
- elseif nlines == '-halfpage' then
- nlines = -math.ceil(self.frame_body.height/2)
- else
- error(('unhandled scroll keyword: "%s"'):format(nlines))
- end
- end
- local n = self.start_line_num + nlines
- n = math.min(n, self:getTextHeight() - self.frame_body.height + 1)
- n = math.max(n, 1)
- 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
- -- 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-1)
- 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)
- end
- ------------------
- -- TooltipLabel --
- ------------------
- TooltipLabel = defclass(TooltipLabel, WrappedLabel)
- TooltipLabel.ATTRS{
- show_tooltip=DEFAULT_NIL,
- indent=2,
- text_pen=COLOR_GREY,
- }
- function TooltipLabel:preUpdateLayout()
- self.visible = getval(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 and self:getMousePos() 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()
- -- initialize option_idx
- for i in ipairs(self.options) do
- if self.initial_option == self:getOptionValue(i) then
- self.option_idx = i
- break
- end
- end
- if not self.option_idx then
- if self.options[self.initial_option] then
- self.option_idx = self.initial_option
- end
- end
- if not self.option_idx then
- error(('cannot find option with value or index: "%s"')
- :format(self.initial_option))
- end
- self:setText{
- {key=self.key, key_sep=': ', text=self.label, width=self.label_width,
- on_activate=self:callback('cycle')},
- ' ',
- {text=self:callback('getOptionLabel')},
- }
- 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:getOptionLabel(option_idx)
- option_idx = option_idx or self.option_idx
- local option = self.options[option_idx]
- if type(option) == 'table' then
- return option.label
- end
- return option
- end
- function CycleHotkeyLabel:getOptionValue(option_idx)
- option_idx = option_idx or self.option_idx
- local option = self.options[option_idx]
- if type(option) == 'table' then
- return option.value
- end
- return option
- end
- function CycleHotkeyLabel:onInput(keys)
- if CycleHotkeyLabel.super.onInput(self, keys) then
- return true
- elseif keys._MOUSE_L and self:getMousePos() then
- self:cycle()
- return true
- end
- end
- -----------------------
- -- ToggleHotkeyLabel --
- -----------------------
- ToggleHotkeyLabel = defclass(ToggleHotkeyLabel, CycleHotkeyLabel)
- ToggleHotkeyLabel.ATTRS{
- options={{label='On', value=true},
- {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 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 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-2-#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:submit()
- if self.on_submit and #self.choices > 0 then
- self.on_submit(self:getSelected())
- end
- end
- function List:submit2()
- if self.on_submit2 and #self.choices > 0 then
- self.on_submit2(self:getSelected())
- end
- end
- function List:onInput(keys)
- if self:inputToSubviews(keys) then
- return true
- end
- if self.on_submit and keys.SELECT then
- self:submit()
- return true
- elseif self.on_submit2 and keys.SEC_SELECT then
- self:submit2()
- return true
- elseif keys._MOUSE_L then
- local _, mouse_y = self:getMousePos()
- if mouse_y and #self.choices > 0 and
- mouse_y < (#self.choices-self.page_top+1) * self.row_height then
- local idx = self.page_top + math.floor(mouse_y/self.row_height)
- self:setSelected(idx)
- self:submit()
- return true
- end
- else
- for k,v in pairs(self.scroll_keys) do
- if keys[k] then
- 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,
- }
- function FilteredList:init(info)
- 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 = self:callback('onFilterChar'),
- 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 or v.text
- 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
Add Comment
Please, Sign In to add comment