mirror of
https://github.com/Reuh/daccord.git
synced 2025-10-27 04:39:30 +00:00
597 lines
16 KiB
Text
597 lines
16 KiB
Text
--- 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
|