local Array = require('opus.array') local Blit = require('opus.ui.blit') local Canvas = require('opus.ui.canvas') local class = require('opus.class') local Event = require('opus.event') local Input = require('opus.input') local Transition = require('opus.ui.transition') local Util = require('opus.util') local _rep = string.rep local colors = _G.colors local device = _G.device local fs = _G.fs local os = _G.os local term = _G.term local textutils = _G.textutils --[[ Using the shorthand window definition, elements are created from the bottom up. Once reaching the top, setParent is called top down. On :init(), elements do not know the parent or can calculate sizing. Calling order: window:postInit() at this point, the window has all default values set window:setParent() parent has been assigned following are called: window:layout() sizing / positioning is performed window:initChildren() each child of window will get initialized ]] --[[-- Top Level Manager --]]-- local UI = { } function UI:init() self.devices = { } self.theme = { colors = { primary = colors.green, secondary = colors.lightGray, tertiary = colors.gray, } } self.extChars = Util.getVersion() >= 1.76 local function keyFunction(event, code, held) local ie = Input:translate(event, code, held) local currentPage = self:getActivePage() if ie and currentPage then local target = currentPage.focused or currentPage target:emit({ type = 'key', key = ie.code == 'char' and ie.ch or ie.code, element = target, ie = ie }) currentPage:sync() end end local function resize(_, side) local dev = self.devices[side or 'terminal'] if dev and dev.currentPage then dev:resize() dev.currentPage:resize() dev.currentPage:draw() dev.currentPage:sync() end end local handlers = { char = keyFunction, key_up = keyFunction, key = keyFunction, term_resize = resize, monitor_resize = resize, mouse_scroll = function(_, direction, x, y, side) local ie = Input:translate('mouse_scroll', direction, x, y) local currentPage = self:getActivePage() if currentPage and currentPage.parent.device.side == side then local event = currentPage:pointToChild(x, y) event.type = ie.code event.ie = { code = ie.code, x = event.x, y = event.y } event.element:emit(event) currentPage:sync() end end, monitor_touch = function(_, side, x, y) local dev = self.devices[side] if dev and dev.currentPage then Input:translate('mouse_click', 1, x, y) local ie = Input:translate('mouse_up', 1, x, y) self:click(dev.currentPage, ie) end end, mouse_click = function(_, button, x, y, side) local ie = Input:translate('mouse_click', button, x, y) local currentPage = self:getActivePage() if currentPage and currentPage.parent.device.side == side then local event = currentPage:pointToChild(x, y) if event.element.focus and not event.element.inactive then currentPage:setFocus(event.element) currentPage:sync() end self:click(currentPage, ie) end end, mouse_up = function(_, button, x, y, side) local ie = Input:translate('mouse_up', button, x, y) local currentPage = self:getActivePage() if ie.code == 'control-shift-mouse_click' then -- hack local event = currentPage:pointToChild(x, y) _ENV.multishell.openTab(_ENV, { path = 'sys/apps/Lua.lua', args = { event.element, self, _ENV }, focused = true }) elseif ie and currentPage and currentPage.parent.device.side == side then self:click(currentPage, ie) end end, mouse_drag = function(_, button, x, y, side) local ie = Input:translate('mouse_drag', button, x, y) local currentPage = self:getActivePage() if ie and currentPage and currentPage.parent.device.side == side then self:click(currentPage, ie) end end, paste = function(_, text) local ie = Input:translate('paste', text) self:emitEvent({ type = 'paste', text = text, ie = ie }) self:getActivePage():sync() end, } -- use 1 handler to single thread all events Event.on({ 'char', 'key_up', 'key', 'term_resize', 'monitor_resize', 'mouse_scroll', 'monitor_touch', 'mouse_click', 'mouse_up', 'mouse_drag', 'paste' }, function(event, ...) handlers[event](event, ...) end) end function UI:configure(appName, ...) local defaults = Util.loadTable('usr/config/' .. appName) or { } if not defaults.device then defaults.device = { } end -- starting a program: gpsServer --display=monitor_3148 --scale=.5 gps local _, options = Util.parse(...) local optionValues = { name = options.display, textScale = tonumber(options.scale), } Util.merge(defaults.device, optionValues) if defaults.device.name then local dev if defaults.device.name == 'terminal' then dev = term.current() else dev = device[defaults.device.name] end if not dev then error('Invalid display device') end self:setDefaultDevice(self.Device({ device = dev, textScale = defaults.device.textScale, })) end if defaults.theme then Util.deepMerge(self.theme, defaults.theme) end end function UI:disableEffects() self.term.effectsEnabled = false end function UI:loadTheme(filename) if fs.exists(filename) then local theme, err = Util.loadTable(filename) if not theme then error(err) end Util.deepMerge(self.theme, theme) end for k,v in pairs(self.theme.colors) do Canvas.colorPalette[k] = Canvas.colorPalette[v] Canvas.grayscalePalette[k] = Canvas.grayscalePalette[v] end end function UI:generateTheme(filename) local t = { } local function getName(d) if type(d) == 'string' then return string.format("'%s'", d) end for c, n in pairs(colors) do if n == d then return 'colors.' .. c end end end for k,v in pairs(self) do if type(v) == 'table' then if v._preload then v._preload() v = self[k] end if v.defaults and v.defaults.UIElement ~= 'Device' then for p,d in pairs(v.defaults) do if p:find('olor') then if not t[k] then t[k] = { } end t[k][p] = getName(d) end end end end end t.colors = { primary = getName(self.colors.primary), secondary = getName(self.colors.secondary), tertiary = getName(self.colors.tertiary), } Util.writeFile(filename, textutils.serialize(t):gsub('(")', '')) end function UI:emitEvent(event) local currentPage = self:getActivePage() if currentPage and currentPage.focused then return currentPage.focused:emit(event) end end function UI:click(target, ie) local clickEvent if ie.code == 'mouse_drag' then local function getPosition(element, x, y) repeat x = x - element.x + 1 y = y - element.y + 1 element = element.parent until not element return x, y end local x, y = getPosition(self.lastClicked, ie.x, ie.y) clickEvent = { element = self.lastClicked, x = x, y = y, dx = ie.dx, dy = ie.dy, } else clickEvent = target:pointToChild(ie.x, ie.y) end -- hack for dropdown menus if ie.code == 'mouse_click' and not clickEvent.element.focus then self:emitEvent({ type = 'mouse_out' }) end if ie.code == 'mouse_doubleclick' then if self.lastClicked ~= clickEvent.element then return end else self.lastClicked = clickEvent.element end clickEvent.button = ie.button clickEvent.type = ie.code clickEvent.key = ie.code clickEvent.ie = { code = ie.code, x = clickEvent.x, y = clickEvent.y } clickEvent.raw = ie if clickEvent.element.focus then target:setFocus(clickEvent.element) end clickEvent.element:emit(clickEvent) target:sync() end function UI:setDefaultDevice(dev) self.term = dev end function UI:addPage(name, page) if not self.pages then self.pages = { } end self.pages[name] = page end function UI:setPages(pages) self.pages = pages end function UI:getPage(pageName) local page = self.pages[pageName] if not page then error('UI:getPage: Invalid page: ' .. tostring(pageName), 2) end return page end function UI:getActivePage(page) if page then return page.parent.currentPage end return self.term.currentPage end function UI:setActivePage(page) page.parent.currentPage = page end function UI:setPage(pageOrName, ...) local page = pageOrName if type(pageOrName) == 'string' then page = self.pages[pageOrName] or error('Invalid page: ' .. pageOrName) end local currentPage = self:getActivePage(page) if page == currentPage then page:draw() else if currentPage then if currentPage.focused then currentPage.focused.focused = false currentPage.focused:focus() end currentPage:disable() page.previousPage = currentPage end self:setActivePage(page) page:enable(...) page:draw() if page.focused then page.focused.focused = true page.focused:focus() end page:sync() end end function UI:getCurrentPage() return self.term.currentPage end function UI:setPreviousPage() if self.term.currentPage.previousPage then local previousPage = self.term.currentPage.previousPage.previousPage self:setPage(self.term.currentPage.previousPage) self.term.currentPage.previousPage = previousPage end end function UI:getDefaults(element, args) local defaults = Util.deepCopy(element.defaults) if args then UI:mergeProperties(defaults, args) end return defaults end function UI:mergeProperties(obj, args) if args then for k,v in pairs(args) do if k == 'accelerators' then if obj.accelerators then Util.merge(obj.accelerators, args.accelerators) else obj[k] = v end else obj[k] = v end end end end function UI:pullEvents(...) local s, m = pcall(Event.pullEvents, ...) self.term:reset() if not s and m then error(m, -1) end end UI.exitPullEvents = Event.exitPullEvents UI.quit = Event.exitPullEvents UI.start = UI.pullEvents UI:init() --[[-- Basic drawable area --]]-- UI.Window = class(Canvas) UI.Window.uid = 1 UI.Window.docs = { } UI.Window.defaults = { UIElement = 'Window', x = 1, y = 1, offx = 0, offy = 0, cursorX = 1, cursorY = 1, } function UI.Window:init(args) -- merge defaults for all subclasses local defaults = args local m = getmetatable(self) -- get the class for this instance repeat if m ~= Canvas then defaults = UI:getDefaults(m, defaults) end m = m._base until not m UI:mergeProperties(self, defaults) -- each element has a unique ID self.uid = UI.Window.uid UI.Window.uid = UI.Window.uid + 1 -- at this time, the object has all the properties set -- postInit is a special constructor. the element does not need to implement -- the method. But we need to guarantee that each subclass which has this -- method is called. m = self local lpi repeat if m.postInit and m.postInit ~= lpi then m.postInit(self) lpi = m.postInit end m = m._base until not m end UI.Window.docs.postInit = [[postInit(VOID) Called once the window has all the properties set. Override to calculate properties or to dynamically add children]] function UI.Window:postInit() if self.parent then -- this will cascade down the whole tree of elements starting at the -- top level window (which has a device as a parent) self:setParent() end end function UI.Window:initChildren() local children = self.children -- insert any UI elements created using the shorthand -- window definition into the children array for k,child in pairs(self) do if k ~= 'parent' then -- reserved if type(child) == 'table' and child.UIElement and not child.parent then if not children then children = { } end table.insert(children, child) end end end if children then for _,child in pairs(children) do if not child.parent then child.parent = self child:setParent() if self.enabled then child:enable() end end end self.children = children end end function UI.Window:layout() local function calc(p, max) p = tonumber(p:match('(%d+)%%')) return p and math.floor(max * p / 100) or 1 end if type(self.x) == 'string' then self.x = calc(self.x, self.parent.width) + 1 -- +1 in order to allow both x and ex to use the same % end if type(self.ex) == 'string' then self.ex = calc(self.ex, self.parent.width) end if type(self.y) == 'string' then self.y = calc(self.y, self.parent.height) + 1 end if type(self.ey) == 'string' then self.ey = calc(self.ey, self.parent.height) end if self.x < 0 then self.x = self.parent.width + self.x + 1 end if self.y < 0 then self.y = self.parent.height + self.y + 1 end if self.ex then local ex = self.ex if self.ex <= 1 then ex = self.parent.width + self.ex + 1 end if self.width then self.x = ex - self.width + 1 else self.width = ex - self.x + 1 end end if self.ey then local ey = self.ey if self.ey <= 1 then ey = self.parent.height + self.ey + 1 end if self.height then self.y = ey - self.height + 1 else self.height = ey - self.y + 1 end end if not self.width then self.width = self.parent.width - self.x + 1 end if not self.height then self.height = self.parent.height - self.y + 1 end self.width = math.max(self.width, 1) self.height = math.max(self.height, 1) self:reposition(self.x, self.y, self.width, self.height) end -- Called when the window's parent has be assigned function UI.Window:setParent() self.oh, self.ow = self.height, self.width self.ox, self.oy = self.x, self.y self.oex, self.oey = self.ex, self.ey self:layout() self:initChildren() end function UI.Window:resize() self.height, self.width = self.oh, self.ow self.x, self.y = self.ox, self.oy self.ex, self.ey = self.oex, self.oey self:layout() if self.children then for child in self:eachChild() do child:resize() end end end function UI.Window:reposition(x, y, w, h) if not self.lines then Canvas.init(self, { x = x, y = y, width = w, height = h, isColor = self.parent.isColor, }) else self:move(x, y) Canvas.resize(self, w, h) end end UI.Window.docs.raise = [[raise(VOID) Raise this window to the top]] function UI.Window:raise() Array.removeByValue(self.parent.children, self) table.insert(self.parent.children, self) self:dirty(true) end UI.Window.docs.add = [[add(TABLE) Add element(s) to a window. Example: page:add({ text = UI.Text { x=5,value='help' } })]] function UI.Window:add(children) UI:mergeProperties(self, children) self:initChildren() end function UI.Window:eachChild() local c = self.children and Util.shallowCopy(self.children) local i = 0 return function() i = i + 1 return c and c[i] end end function UI.Window:remove() Array.removeByValue(self.parent.children, self) self.parent:dirty(true) end function UI.Window:getCursorPos() return self.cursorX, self.cursorY end function UI.Window:setCursorPos(x, y) self.cursorX = x self.cursorY = y self.parent:setCursorPos(self.x + x - 1, self.y + y - 1) end function UI.Window:setCursorBlink(blink) self.cursorBlink = blink end UI.Window.docs.draw = [[draw(VOID) Redraws the window in the internal buffer.]] function UI.Window:draw() self:clear() self:drawChildren() end function UI.Window:drawChildren() for child in self:eachChild() do if child.enabled then child:draw() end end end UI.Window.docs.getDoc = [[getDoc(STRING method) Get the documentation for a method.]] function UI.Window:getDoc(method) local m = getmetatable(self) -- get the class for this instance repeat if m.docs and m.docs[method] then return m.docs[method] end m = m._base until not m end UI.Window.docs.sync = [[sync(VOID) Invoke a screen update. Automatically called at top level after an input event. Call to force a screen update.]] function UI.Window:sync() if self.parent then self.parent:sync() end end function UI.Window:enable(...) if not self.enabled then self.enabled = true if self.transitionHint then self:addTransition(self.transitionHint) end if self.modal then self:raise() self:capture(self) end for child in self:eachChild() do if not child.enabled then child:enable(...) end end end end function UI.Window:disable() if self.enabled then self.enabled = false self.parent:dirty(true) if self.modal then self:release(self) end for child in self:eachChild() do if child.enabled then child:disable() end end end end function UI.Window:setTextScale(textScale) self.textScale = textScale self.parent:setTextScale(textScale) end UI.Window.docs.clear = [[clear(opt COLOR bg, opt COLOR fg) Clears the window using either the passed values or the defaults for that window.]] function UI.Window:clear(bg, fg) Canvas.clear(self, bg or self:getProperty('backgroundColor'), fg or self:getProperty('textColor')) end UI.Window.docs.clearLine = [[clearLine(NUMBER y, opt COLOR bg) Clears the specified line.]] function UI.Window:clearLine(y, bg) self:write(1, y, _rep(' ', self.width), bg) end function UI.Window:clearArea(x, y, width, height, bg) self:fillArea(x, y, width, height, ' ', bg) end function UI.Window:fillArea(x, y, width, height, fillChar, bg, fg) if width > 0 then local filler = _rep(fillChar, width) for i = 0, height - 1 do self:write(x, y + i, filler, bg, fg) end end end UI.Window.docs.write = [[write(NUMBER x, NUMBER y, STRING text, opt COLOR bg, opt COLOR fg) Write text to the canvas. If colors are not specified, the colors from the base class will be used. If the base class does not have colors defined, colors will be inherited from the parent container.]] function UI.Window:write(x, y, text, bg, fg) Canvas.write(self, x, y, text, bg or self:getProperty('backgroundColor'), fg or self:getProperty('textColor')) end function UI.Window:centeredWrite(y, text, bg, fg) if #text >= self.width then self:write(1, y, text, bg, fg) else local x = math.floor((self.width-#text) / 2) + 1 self:write(x, y, text, bg, fg) end end function UI.Window:print(text, bg, fg) local marginLeft = self.marginLeft or 0 local marginRight = self.marginRight or 0 local width = self.width - marginLeft - marginRight local cs = { bg = bg or self:getProperty('backgroundColor'), fg = fg or self:getProperty('textColor'), palette = self.palette, } local y = (self.marginTop or 0) + 1 for _,line in pairs(Util.split(text)) do for _, ln in ipairs(Blit(line, cs):wrap(width)) do self:blit(marginLeft + 1, y, ln.text, ln.bg, ln.fg) y = y + 1 end end end UI.Window.docs.focus = [[focus(VOID) If the function is present on a class, it indicates that this element can accept focus. Called when receiving focus.]] UI.Window.docs.setFocus = [[setFocus(ELEMENT el) Set the page's focus to the passed element.]] function UI.Window:setFocus(focus) if self.parent then self.parent:setFocus(focus) end end UI.Window.docs.capture = [[capture(ELEMENT el) Restricts input to the passed element's tree.]] function UI.Window:capture(child) if self.parent then self.parent:capture(child) end end function UI.Window:release(child) if self.parent then self.parent:release(child) end end function UI.Window:pointToChild(x, y) x = x + self.offx - self.x + 1 y = y + self.offy - self.y + 1 if self.children then for i = #self.children, 1, -1 do local child = self.children[i] if child.enabled and not child.inactive and x >= child.x and x < child.x + child.width and y >= child.y and y < child.y + child.height then local c = child:pointToChild(x, y) if c then return c end end end end return { element = self, x = x, y = y } end UI.Window.docs.getFocusables = [[getFocusables(VOID) Returns a list of children that can accept focus.]] function UI.Window:getFocusables() local focusable = { } local function focusSort(a, b) if a.y == b.y then return a.x < b.x end return a.y < b.y end local function getFocusable(parent) for _,child in Util.spairs(parent.children, focusSort) do if child.enabled and child.focus and not child.inactive then table.insert(focusable, child) end if child.children then getFocusable(child) end end end if self.children then getFocusable(self) end return focusable end function UI.Window:focusFirst() local focusables = self:getFocusables() local focused = focusables[1] if focused then self:setFocus(focused) end end function UI.Window:scrollIntoView() local parent = self.parent local offx, offy = parent.offx, parent.offy if self.x <= parent.offx then parent.offx = math.max(0, self.x - 1) if offx ~= parent.offx then parent:draw() end elseif self.x + self.width > parent.width + parent.offx then parent.offx = self.x + self.width - parent.width - 1 if offx ~= parent.offx then parent:draw() end end -- TODO: fix local function setOffset(y) parent.offy = y if offy ~= parent.offy then parent:draw() end end if self.y <= parent.offy then setOffset(math.max(0, self.y - 1)) elseif self.y + self.height > parent.height + parent.offy then setOffset(self.y + self.height - parent.height - 1) end end function UI.Window:addTransition(effect, args, canvas) self.parent:addTransition(effect, args, canvas or self) end UI.Window.docs.emit = [[emit(TABLE event) Send an event to the element. The event handler for the element is called. If the event handler returns true, then no further processing is done. If the event handler does not return true, then the event is sent to the parent element and continues up the element tree. If an accelerator is defined, the accelerated event is processed in the same manner. Accelerators are useful for making events unique.]] function UI.Window:emit(event) local parent = self while parent do if parent.accelerators then -- events types can be made unique via accelerators local acc = parent.accelerators[event.key or event.type] if acc and acc ~= event.type then -- don't get stuck in a loop local event2 = Util.shallowCopy(event) event2.type = acc event2.key = nil if parent:emit(event2) then return true end end end if parent.eventHandler then if parent:eventHandler(event) then return true end end parent = parent.parent end end function UI.Window:getProperty(property) return self[property] or self.parent and self.parent:getProperty(property) end function UI.Window:find(uid) local el = self.children and Util.find(self.children, 'uid', uid) if not el then for child in self:eachChild() do el = child:find(uid) if el then break end end end return el end function UI.Window:eventHandler() return false end --[[-- Terminal for computer / advanced computer / monitor --]]-- UI.Device = class(UI.Window) UI.Device.defaults = { UIElement = 'Device', backgroundColor = colors.black, textColor = colors.white, textScale = 1, effectsEnabled = true, } function UI.Device:postInit() self.device = self.device or term.current() if not self.device.setTextScale then self.device.setTextScale = function() end end self.device.setTextScale(self.textScale) self.width, self.height = self.device.getSize() self.isColor = self.device.isColor() Canvas.init(self, { isColor = self.isColor }) UI.devices[self.device.side or 'terminal'] = self end function UI.Device:resize() self.device.setTextScale(self.textScale) self.width, self.height = self.device.getSize() self.lines = { } -- TODO: resize all pages added to this device Canvas.resize(self, self.width, self.height) Canvas.clear(self, self.backgroundColor, self.textColor) end function UI.Device:setCursorPos(x, y) self.cursorX = x self.cursorY = y end function UI.Device:getCursorBlink() return self.cursorBlink end function UI.Device:setCursorBlink(blink) self.cursorBlink = blink end function UI.Device:setTextScale(textScale) self.textScale = textScale self.device.setTextScale(self.textScale) end function UI.Device:reset() self.device.setBackgroundColor(colors.black) self.device.setTextColor(colors.white) self.device.clear() self.device.setCursorPos(1, 1) end function UI.Device:addTransition(effect, args, canvas) if not self.transitions then self.transitions = { } end if type(effect) == 'string' then effect = Transition[effect] or error('Invalid transition') end -- there can be only one for k,v in pairs(self.transitions) do if v.canvas == canvas then table.remove(self.transitions, k) break end end table.insert(self.transitions, { effect = effect, args = args or { }, canvas = canvas }) end function UI.Device:runTransitions(transitions) for _,k in pairs(transitions) do k.update = k.effect(k.canvas, k.args) end while true do for _,k in ipairs(Util.keys(transitions)) do local transition = transitions[k] if not transition.update() then transitions[k] = nil end end self.currentPage:render(self, true) if Util.empty(transitions) then break end os.sleep(0) end end function UI.Device:sync() local transitions = self.effectsEnabled and self.transitions self.transitions = nil self.device.setCursorBlink(false) if transitions then self:runTransitions(transitions) else self.currentPage:render(self, true) end if self:getCursorBlink() then self.device.setCursorPos(self.cursorX, self.cursorY) if self.isColor then self.device.setTextColor(colors.orange) end self.device.setCursorBlink(true) end end -- lazy load components local function loadComponents() local function load(name) local s, m = Util.run(_ENV, 'sys/modules/opus/ui/components/' .. name .. '.lua') if not s then error(m) end if UI[name]._preload then error('Error loading UI.' .. name) end if UI.theme[name] and UI[name].defaults then Util.merge(UI[name].defaults, UI.theme[name]) end return UI[name] end local components = fs.list('sys/modules/opus/ui/components') for _, f in pairs(components) do local name = f:match('(.+)%.') UI[name] = setmetatable({ }, { __call = function(self, ...) load(name) setmetatable(self, getmetatable(UI[name])) return self(...) end }) UI[name]._preload = function() return load(name) end end end loadComponents() UI:loadTheme('usr/config/ui.theme') UI:setDefaultDevice(UI.Device()) return UI