--- Yet another badly designed and implemented GUI library, but this time I wrote it so it's the best. -- Part of daccord. See daccord.can for more information. -- Copyright 2017-2018 Étienne "Reuh" Fildadut -- -- Licensed under the Apache License, Version 2.0 (the "License"); -- you may not use this file except in compliance with the License. -- You may obtain a copy of the License at -- -- http://www.apache.org/licenses/LICENSE-2.0 -- -- Unless required by applicable law or agreed to in writing, software -- distributed under the License is distributed on an "AS IS" BASIS, -- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -- See the License for the specific language governing permissions and -- limitations under the License. let curses = require("curses") let class = require("classtoi") let sleep = require("socket").sleep let screen os.setlocale("") let everyWidget = {} let widget = class { id = nil, -- (optional) widget id type = nil, -- widget type width = nil, -- height height = nil, -- width focused = false, -- true if take inputs updateInterval = -1, -- time in seconds between updates new = :(data) -- create a new widget table.insert(everyWidget, self) -- Copy properties for k, v in pairs(data) do @[k] = v end -- Dimensions if not @_parent._firstX then @_parent._firstX, @_parent._firstY = 0, 0 end @_x, @_y = @_parent._firstX, @_parent._firstY if @width == "extend" @_w = @_parent._w - @_x elseif @width:match("%d+em$") @_w = tonumber(@width:match("%d+")) if @height == "extend" then @_h = @_parent._h - @_y @_parent._lastVerticalExtend = self -- will be resized if vertical space is needed @_parent._afterLastVerticalExtend = {} elseif @height:match("%d+em$") @_h = tonumber(@height:match("%d+")) if @_y >= @_parent._h then -- resize @_parent._lastVerticalExtend:_resize(@_parent._lastVerticalExtend._w, @_parent._lastVerticalExtend._h - @_h) @_parent._firstY -= @_h @_y -= @_h for _, el in ipairs(@_parent._afterLastVerticalExtend) do -- move widgets el._y -= @_h end end @_parent._firstX += @_w if @_parent._firstX >= @_parent._w then @_parent._firstX = 0 end -- newline @_parent._firstY += @_h if @_parent._lastVerticalExtend ~= self and @_parent._afterLastVerticalExtend then table.insert(@_parent._afterLastVerticalExtend, self) end -- Setup if @_setup then @_setup() end screen:move(@_parent._y + @_y + @_h, @_parent._x + @_x + @_w) end, exit = :() -- exit the application @_exit = true end, byId = :(id) -- return a widget by its id for _, el in ipairs(everyWidget) do if el.id == id return el end error("no element with id "..tostring(id)) end, updateAfter = :(time) -- reschedule the next widget update @_nextUpdate = os.time() + time end, _parent = {}, _exit = false, _redraw = true, _x = 0, _y = 0, _w = 0, _h = 0, _nextUpdate = os.time(), -- -1 = no update } let widgets = setmetatable({ fill = widget { fill = nil, -- filling type _draw = :() if type(@fill) == "string" then @fill = curses[@fill] end for y = @_parent._y + @_y, @_parent._y + @_y + @_h - 1 do screen:move(y, @_parent._x + @_x) for x = @_parent._x + @_x, @_parent._x + @_x + @_w - 1 do screen:addch(@fill or 32) end end end }, input = widget { content = "", -- text content cursorPosition = 1, -- cursor position, first character is 1 sub = :(start, stop=utf8.len(@content)) -- get the substring if stop < 1 or start >= utf8.len(@content) then return "" else return @content:sub(utf8.offset(@content, start), (utf8.offset(@content, stop+1) or (#@content+1))-1) end end, replace = :(start, stop, newText) -- replace a substring if @cursorPosition >= stop then @cursorPosition += utf8.len(newText) - (stop - start) end @content = @sub(1, start-1) .. newText .. @sub(stop+1) @onTextInput() @_redraw = true end, onTextInput = :() end, -- called when the text change _input = :(charbuffer, control) let y, x = @_parent._y + @_y, @_parent._x + @_x + @cursorPosition-1 if control == "backspace" then screen:mvdelch(y, x-1) if @cursorPosition > 1 then if @cursorPosition == 2 then -- utf8.offset(s, 0) returns the start of the last character, ie something we don't want @content = @content:sub(utf8.offset(@content, @cursorPosition)) else @content = @content:sub(1, utf8.offset(@content, @cursorPosition-1)-1) .. @content:sub(utf8.offset(@content, @cursorPosition)) end @cursorPosition -= 1 @onTextInput() end elseif control == "delete" then screen:mvdelch(y, x) if @cursorPosition <= utf8.len(@content) then if @cursorPosition == 1 then -- utf8.offset(s, 0) returns the start of the last character, ie something we don't want @content = @content:sub(utf8.offset(@content, @cursorPosition+1)) else @content = @content:sub(1, utf8.offset(@content, @cursorPosition)-1) .. @content:sub(utf8.offset(@content, @cursorPosition+1)) end @onTextInput() end elseif control == "right" then if @cursorPosition <= utf8.len(@content) then screen:addstr(@content:sub(utf8.offset(@content, @cursorPosition), utf8.offset(@content, @cursorPosition+1)-1)) @cursorPosition += 1 end elseif control == "left" then if @cursorPosition > 1 then screen:move(y, x-1) @cursorPosition -= 1 end elseif charbuffer then screen:move(y, x) screen:winsstr(charbuffer) screen:move(y, x+1) @content = @content:sub(1, utf8.offset(@content, @cursorPosition)-1) .. charbuffer .. @content:sub(utf8.offset(@content, @cursorPosition)) @cursorPosition += 1 @onTextInput() end end, _draw = :() for y = @_parent._y + @_y, @_parent._y + @_y + @_h - 1 do screen:move(y, @_parent._x + @_x) for x = @_parent._x + @_x, @_parent._x + @_x + @_w - 1 do screen:addch(32) end end screen:mvaddstr(@_parent._y + @_y, @_parent._x + @_x, @content) end, _placeCursor = :() screen:move(@_parent._y + @_y, @_parent._x + @_x + @cursorPosition-1) return true end }, list = widget { content = {}, -- list content (list of tables) selected = 1, -- last selected line pump = nil, -- function used to pump. See :setPump(). insert = :(pos, item) -- insert a line, shifting elements after it if item then table.insert(@content, pos, item) if @selected > pos and @selected < #@content then @selected += 1 end else item = pos table.insert(@content, item) end if #@content == 1 then -- column count is determined by 1st item (other items can do what they want, this isn't a dictatorship) @_columnWidth = [for _=1,#item do 0 end] end if #item >= #@_columnWidth then -- if the column fits into our dictatorship, update column width for c=1, #@_columnWidth do let l = utf8.len(item[c]) -- if it isn't valid UTF8, ignore (can happen for files in Windows-made zipfiles). Should probably raise a warning or something... TODO. if l and l > @_columnWidth[c] then @_columnWidth[c] = utf8.len(item[c]) + 1 end end end @_redraw = true end, remove = :(pos=#@content) -- remove a line, shifting elements after it table.remove(@content, pos) if @selected > pos and @selected > 1 then @selected -= 1 end @selected = math.min(@selected, #@content) @_redraw = true end, replace = :(pos, item) -- replace a line @content[pos] = item if #@content == 1 then -- column count is determined by 1st item (other items can do what they want, this isn't a dictatorship) @_columnWidth = [for _=1,#item do 0 end] end if #item >= #@_columnWidth then -- if the column fits into our dictatorship, update column width for c=1, #@_columnWidth do let l = utf8.len(item[c]) -- if it isn't valid UTF8, ignore (can happen for files in Windows-made zipfiles). Should probably raise a warning or something... TODO. if l and l > @_columnWidth[c] then @_columnWidth[c] = utf8.len(item[c]) + 1 end end end @_redraw = true end, -- Give a pump function (startPosition, stopPosition) -> {tables...} which load the lines between startPosition and stopPosition. -- The pump may returns the lines instead of inserting them itself. -- step is the preferance of number of line to be loaded at one. setPump = :(step, newPump) @pump = newPump @_pumpStep = step if #@content == 0 then let pumped = @pump(1, math.max(@_h, @_pumpStep)) if pumped then for _, l in ipairs(pumped) do @insert(l) end end end end, repump = :() -- force already pumped elements to be repumped. The rest of the list state will be kept. @content = {} @_columnWidth = {} let pumped = @pump(1, math.max(@_scroll+@_h, @_scroll+@_pumpStep)) -- TODO: change everything so we only need to pump what is currently displayed if pumped then for i, l in ipairs(pumped) do @replace(@_scroll+i-1, l) end end end, clear = :() -- reset list: content, current selection, current pump @content = {} @_columnWidth = {} @_redraw = true @selected = 1 @_scroll = 0 @pump = nil end, onSelect = :() end, -- called when selecting a line _columnWidth = {}, _scroll = 0, _pumpStep = 5, _redraw = true, _input = :(charbuffer, control) if control == "up" then @selected -= 1 @_redraw = true elseif control == "down" then @selected += 1 @_redraw = true elseif control == "pgup" then @selected -= 10 @_redraw = true elseif control == "pgdown" then @selected += 10 @_redraw = true end if @pump and @selected > #@content then -- pump data if needed let pumped = @pump(#@content+1, #@content+@_pumpStep+1) if pumped then for _, l in ipairs(pumped) do @insert(l) end end end @selected = math.min(math.max(@selected, 1), math.max(#@content, 1)) while @selected <= @_scroll do @_scroll -= 1 end while @selected >= @_scroll + @_h + 1 do @_scroll += 1 end if control == "enter" then @onSelect({@selected}) elseif control == "A" then let len = #@content @onSelect([for i=1, len do i end]) end end, _draw = :() let oY, oX = screen:getyx() for y = @_parent._y + @_y, @_parent._y + @_y + @_h -1 do -- clear screen:mvaddstr(y, @_parent._x + @_x, (" "):rep(@_w)) end screen:move(@_parent._y + @_y, @_parent._x + @_x) for i=@_scroll+1, @_scroll + @_h do -- draw if i == @selected then screen:standout() end let colx = @_parent._x+@_x for c=1, #@_columnWidth do screen:mvaddstr(@_parent._y+@_y+i-1-@_scroll, colx, @content[i] and @content[i][c] or "") -- TODO: make sure it doesn't go too far right or something colx += @_columnWidth[c] end if i == @selected then screen:standend() end end screen:move(oY, oX) end }, tabs = widget { selected = 1, -- selected tab children = {}, -- children elements _children = {}, _setup = :() for i, tab in ipairs(@) do @children[i] = { _x = @_x, _y = @_y, _w = @_w, _h = @_h } for _, el in ipairs(tab) do el._parent = @children[i] table.insert(@children[i], widgets[el.type]:new(el)) end end @_children = @children[@selected] end, _input = :(charbuffer, control) if control == "tab" then @selected += 1 if @selected > #@ then @selected = 1 end @_children = @children[@selected] for _, el in ipairs(@_children) do -- Force redraw el._redraw = true end end end, _resize = :(w, h) @_w, @_h = w, h for i, tab in ipairs(@) do for _, el in ipairs(@children[i]) do if el._x + el._w > w then el._w = w - el._x end if el._y + el._h > h then el._h = h - el._y end end end end }, label = widget { content = "Label", -- text set = :(str) -- set text @content = str @_redraw = true end, _draw = :() screen:mvaddstr(@_parent._y + @_y, @_parent._x + @_x, @content .. (" "):rep(@_w - #@content)) end }, slider = widget { min = 0, -- min value max = 1, -- max value value = 0, -- current value head = "⏸", -- associated symbol set = :(value) -- change value @value = value @_redraw = true end, setMax = :(max) -- change maximum @max = max @_redraw = true end, setMin = :(min) -- change minimum @min = min @_redraw = true end, setHead = :(head) -- change symbol @head = head @_redraw = true end, _draw = :() let len = math.ceil((@value - @min) / (@max - @min) * @_w) screen:mvaddstr(@_parent._y + @_y, @_parent._x + @_x, ("="):rep(len-1)) screen:addstr(@head .. (" "):rep(@_w - len)) end, } }, { __index = (t, k) error("unknown widget "..tostring(k)) end }) let recursiveApply = (list, fn) for _, el in ipairs(list) do if el._widget then el = el._widget end fn(el) if el._children then recursiveApply(el._children, fn) end end end return (ui) xpcall(() -- Init if not screen then screen = curses.initscr() curses.cbreak() curses.echo(false) screen:nodelay(true) end -- Create widgets screen:clear() let h, w = screen:getmaxyx() let parent = { _x = 0, _y = 0, _w = w, _h = h } for _, el in ipairs(ui) do el._parent = parent el._widget = widgets[el.type]:new(el) end -- Update loop while not widget._exit do -- Input local c = screen:getch() if c and c < 256 then let charbuffer = string.char(c) let control if c > 127 then -- multibyte char charbuffer ..= string.char(screen:getch()) if c > 223 then charbuffer ..= string.char(screen:getch()) if c > 239 then charbuffer ..= string.char(screen:getch()) end end end if curses.unctrl(c):match("^%^") then -- control char charbuffer = nil let k = curses.unctrl(c) if k == "^?" then control = "backspace" elseif k == "^W" then control = "close" elseif k == "^J" then control = "enter" elseif k == "^I" then control = "tab" elseif k == "^@" then control = "space" elseif k == "^[" then let k = string.char(screen:getch()) .. string.char(screen:getch()) if k == "[C" then control = "right" elseif k == "[D" then control = "left" elseif k == "[A" then control = "up" elseif k == "[B" then control = "down" elseif k == "[5" then k ..= string.char(screen:getch()) if k == "[5~" then control = "pgup" else error("unknown control "..tostring(k)) end elseif k == "[6" then k ..= string.char(screen:getch()) if k == "[6~" then control = "pgdown" else error("unknown control "..tostring(k)) end elseif k == "[3" then k ..= string.char(screen:getch()) if k == "[3~" then control = "delete" elseif k == "[3;" then control = "clear" else error("unknown control "..tostring(k)) end else error("unknown control "..tostring(k)) end elseif k:match("^%^[A-Z]$") then control = k:match("^%^([A-Z])$") else error("unknown control "..tostring(k)) end end recursiveApply(ui, (el) if el.focused and el._input then el:_input(charbuffer, control) end if el.focused and control and el.onControl then el:onControl(control) end end) if control == "close" then if ui.onClose then ui.onClose(widget) end end end -- Update recursiveApply(ui, (el) if el._nextUpdate ~= -1 and os.difftime(el._nextUpdate, os.time()) <= 0 then if el.onUpdate then el:onUpdate() end if el.updateInterval ~= -1 then el._nextUpdate += el.updateInterval else el._nextUpdate = -1 end end end) -- Redraw recursiveApply(ui, (el) if el._redraw and el._draw then el._redraw = false el:_draw() end end) -- Place cursor let cursorVis = 0 recursiveApply(ui, (el) if el._placeCursor and el:_placeCursor() then cursorVis = 2 end end) curses.curs_set(cursorVis) -- Done screen:refresh() sleep(0.03) end curses.endwin() end, (err) curses.endwin() print(require("candran").messageHandler(err)) os.exit(2) end) end