local Alt = require('opus.alternate') local Array = require('opus.array') local class = require('opus.class') local Config = require('opus.config') local Event = require('opus.event') local NFT = require('opus.nft') local Packages = require('opus.packages') local SHA = require('opus.crypto.sha2') local Tween = require('opus.ui.tween') local UI = require('opus.ui') local Util = require('opus.util') local device = _G.device local fs = _G.fs local os = _G.os local pocket = _G.pocket local shell = _ENV.shell local term = _G.term local turtle = _G.turtle --[[ turtle: 39x13 computer: 51x19 pocket: 26x20 ]] if not _ENV.multishell then error('multishell is required') end local REGISTRY_DIR = 'usr/.registry' local DEFAULT_ICON = NFT.parse("\0308\0317\153\153\153\153\153\ \0307\0318\153\153\153\153\153\ \0308\0317\153\153\153\153\153") local TRANS_ICON = NFT.parse("\0302\0312\32\32\32\32\32\ \0302\0312\32\32\32\32\32\ \0302\0312\32\32\32\32\32") -- overview local uid = _ENV.multishell.getCurrent() device.keyboard.addHotkey('control-o', function() _ENV.multishell.setFocus(uid) end) UI:configure('Overview', ...) local config = { Recent = { }, currentCategory = 'Apps', } Config.load('Overview', config) local extSupport = Util.getVersion() >= 1.76 local applications = { } local buttons = { } local sx, sy = term.current().getSize() local maxRecent = math.ceil(sx * sy / 62) local function ellipsis(s, len) if #s > len then s = s:sub(1, len - 2) .. '..' end return s end local function parseIcon(iconText) local icon local s, m = pcall(function() icon = NFT.parse(iconText) if icon then if icon.height > 3 or icon.width > 8 then error('Must be an NFT image - 3 rows, 8 cols max') end NFT.transparency(icon) end return icon end) if s then return icon end return s, m end local page = UI.Page { container = UI.Viewport { x = 9, y = 1, }, tabBar = UI.TabBar { ey = -2, width = 8, selectedBackgroundColor = 'primary', backgroundColor = 'tertiary', layout = function(self) self.height = nil UI.TabBar.layout(self) end, }, tray = UI.Window { y = -1, width = 8, backgroundColor = 'tertiary', newApp = UI.FlatButton { x = 2, text = '+', event = 'new', }, mode = UI.FlatButton { x = 4, text = '=', event = 'display_mode', }, help = UI.FlatButton { x = 6, text = '?', event = 'help', }, }, editor = UI.SlideOut { y = -12, height = 12, titleBar = UI.TitleBar { title = 'Edit Application', event = 'slide_hide', }, form = UI.Form { y = 2, ey = -2, [1] = UI.TextEntry { formLabel = 'Title', formKey = 'title', limit = 11, width = 13, help = 'Application title', required = true, }, [2] = UI.TextEntry { formLabel = 'Run', formKey = 'run', limit = 100, help = 'Full path to application', required = true, }, [3] = UI.TextEntry { formLabel = 'Category', formKey = 'category', limit = 6, width = 8, help = 'Category of application', required = true, }, editIcon = UI.Button { x = 11, y = 6, text = 'Edit', event = 'editIcon', help = 'Edit icon file', }, loadIcon = UI.Button { x = 11, y = 8, text = 'Load', event = 'loadIcon', help = 'Load icon file', }, helpIcon = UI.Button { x = 11, y = 8, text = 'Load', event = 'loadIcon', help = 'Load icon file', }, image = UI.NftImage { backgroundColor = 'black', y = 6, x = 2, height = 3, width = 8, }, }, file_open = UI.FileSelect { modal = true, enable = function() end, transitionHint = 'expandUp', show = function(self) UI.FileSelect.enable(self) self:focusFirst() self:draw() end, disable = function(self) UI.FileSelect.disable(self) self.parent:focusFirst() -- need to recapture as we are opening a modal within another modal self.parent:capture(self.parent) end, eventHandler = function(self, event) if event.type == 'select_cancel' then self:disable() elseif event.type == 'select_file' then self:disable() end return UI.FileSelect.eventHandler(self, event) end, }, notification = UI.Notification(), statusBar = UI.StatusBar(), }, notification = UI.Notification(), accelerators = { r = 'refresh', e = 'edit', f = 'files', s = 'shell', l = 'lua', n = 'network', [ 'control-n' ] = 'new', delete = 'delete', }, } local function loadApplications() local requirements = { turtle = not not turtle, advancedTurtle = turtle and term.isColor(), advanced = term.isColor(), pocket = not not pocket, advancedPocket = pocket and term.isColor(), advancedComputer = not turtle and not pocket and term.isColor(), neuralInterface = not not device.neuralInterface, } applications = Util.readTable('sys/etc/apps.db') for dir in pairs(Packages:installed()) do local path = fs.combine('packages/' .. dir, 'etc/apps.db') if fs.exists(path) then local apps = Util.readTable(path) or { } Util.merge(applications, apps) end end if fs.exists(REGISTRY_DIR) then local files = fs.list(REGISTRY_DIR) for _,file in pairs(files) do local app = Util.readTable(fs.combine(REGISTRY_DIR, file)) if app and app.key then app.filename = fs.combine(REGISTRY_DIR, file) applications[app.key] = app end end end Util.each(applications, function(v, k) v.key = k end) applications = Util.filter(applications, function(a) if a.disabled then return false end if a.requires then return requirements[a.requires] end return true end) local categories = { } buttons = { } for _,f in pairs(applications) do if not categories[f.category] then categories[f.category] = true table.insert(buttons, { text = f.category, width = 8, selected = config.currentCategory == f.category }) end end table.sort(buttons, function(a, b) return a.text < b.text end) table.insert(buttons, 1, { text = 'Recent' }) for k,v in pairs(buttons) do v.x = 1 v.y = k + 1 end page.tabBar.children = { } page.tabBar:addButtons(buttons) --page.tabBar:selectTab(config.currentCategory or 'Apps') page.container:setCategory(config.currentCategory or 'Apps') end UI.Icon = class(UI.Window) UI.Icon.defaults = { UIElement = 'Icon', width = 14, height = 4, } function UI.Icon:eventHandler(event) if event.type == 'mouse_click' then self:setFocus(self.button) return true elseif event.type == 'mouse_doubleclick' then self:emit({ type = self.button.event, button = self.button }) elseif event.type == 'mouse_rightclick' then self:setFocus(self.button) self:emit({ type = 'edit', button = self.button }) end return UI.Window.eventHandler(self, event) end function page.container:setCategory(categoryName, animate) -- reset the viewport window self.children = { } self:reset() local filtered = { } if categoryName == 'Recent' then for _,v in ipairs(config.Recent) do local app = Util.find(applications, 'key', v) if app then table.insert(filtered, app) end end else filtered = Array.filter(applications, function(a) return a.category == categoryName end) table.sort(filtered, function(a, b) return a.title < b.title end) end for _,program in ipairs(filtered) do local icon if extSupport and program.iconExt then icon = parseIcon(program.iconExt) end if not icon and program.icon then icon = parseIcon(program.icon) end if not icon then icon = DEFAULT_ICON end local title = ellipsis(program.title, 8) local width = math.max(icon.width + 2, #title + 2) if config.listMode then table.insert(self.children, UI.Icon { width = self.width - 2, height = 1, UI.Button { x = 1, ex = -1, text = program.title, centered = false, backgroundColor = self:getProperty('backgroundColor'), backgroundFocusColor = 'gray', textColor = 'white', textFocusColor = 'white', event = 'button', app = program, } }) else table.insert(self.children, UI.Icon({ width = width, image = UI.NftImage({ x = math.floor((width - icon.width) / 2) + 1, image = icon, }), button = UI.Button({ x = math.floor((width - #title - 2) / 2) + 1, y = 4, text = title, backgroundColor = self:getProperty('backgroundColor'), backgroundFocusColor = 'gray', textColor = 'white', textFocusColor = 'white', width = #title + 2, event = 'button', app = program, }), })) end end local gutter = 2 if UI.term.width <= 26 then gutter = 1 end local col, row = gutter, 2 local count = #self.children local r = math.random(1, 7) local frames = 5 -- reposition all children for k,child in ipairs(self.children) do if r == 1 then child.x = math.random(1, self.width) child.y = math.random(1, self.height - 3) elseif r == 2 then child.x = self.width child.y = self.height - 3 elseif r == 3 then child.x = math.floor(self.width / 2) child.y = math.floor(self.height / 2) elseif r == 4 then child.x = self.width - col child.y = row elseif r == 5 then child.x = col child.y = row if k == #self.children then child.x = self.width child.y = self.height - 3 end elseif r == 6 then child.x = col child.y = 1 elseif r == 7 then child.x = 1 child.y = self.height - 3 end child.tween = Tween.new(frames, child, { x = col, y = row }, 'inQuad') if not animate then child.x = col child.y = row end self:setViewHeight(row + (config.listMode and 1 or 4)) if k < count then col = col + child.width if col + self.children[k + 1].width + gutter - 2 > self.width then col = gutter row = row + (config.listMode and 1 or 5) end end end self:initChildren() if animate then local function transition() local i = 1 return function() for _,child in pairs(self.children) do child.tween:update(1) child:move(math.floor(child.x), math.floor(child.y)) end i = i + 1 return i <= frames end end self:addTransition(transition) end end function page:refresh() local pos = self.container.offy self:focusFirst(self) self.container:setCategory(config.currentCategory) self.container:setScrollPosition(pos) end function page:resize() UI.Page.resize(self) self:refresh() end function page:eventHandler(event) if event.type == 'tab_select' then self.container:setCategory(event.button.text, true) self.container:draw() config.currentCategory = event.button.text Config.update('Overview', config) elseif event.type == 'button' then for k,v in ipairs(config.Recent) do if v == event.button.app.key then table.remove(config.Recent, k) break end end table.insert(config.Recent, 1, event.button.app.key) if #config.Recent > maxRecent then table.remove(config.Recent, maxRecent + 1) end Config.update('Overview', config) shell.switchTab(shell.openTab(event.button.app.run)) elseif event.type == 'shell' then shell.switchTab(shell.openTab(Alt.get('shell'))) elseif event.type == 'lua' then shell.switchTab(shell.openTab(Alt.get('lua'))) elseif event.type == 'files' then shell.switchTab(shell.openTab(Alt.get('files'))) elseif event.type == 'network' then shell.switchTab(shell.openTab('network')) elseif event.type == 'help' then shell.switchTab(shell.openTab('Help Overview')) elseif event.type == 'focus_change' then if event.focused.parent.UIElement == 'Icon' then event.focused.parent:scrollIntoView() end elseif event.type == 'refresh' then -- remove this after fixing notification loadApplications() self:refresh() self:draw() self.notification:success('Refreshed') elseif event.type == 'delete' then local focused = page:getFocused() if focused.app then if focused.app.filename then fs.delete(focused.app.filename) else focused.app.disabled = true local filename = focused.app.filename or fs.combine(REGISTRY_DIR, focused.app.key) Util.writeTable(filename, focused.app) end loadApplications() page:refresh() page:draw() self.notification:success('Removed') end elseif event.type == 'new' then local category = 'Apps' if config.currentCategory ~= 'Recent' then category = config.currentCategory or 'Apps' end self.editor:show({ category = category }) elseif event.type == 'display_mode' then config.listMode = not config.listMode Config.update('Overview', config) loadApplications() self:refresh() self:draw() elseif event.type == 'edit' then local focused = page:getFocused() if focused.app then self.editor:show(focused.app) end else return UI.Page.eventHandler(self, event) end return true end function page.editor:show(app) if app then self.form:setValues(app) local icon if extSupport and app.iconExt then icon = parseIcon(app.iconExt) end if not icon and app.icon then icon = parseIcon(app.icon) end self.form.image:setImage(icon) end UI.SlideOut.show(self) self:focusFirst() end function page.editor:updateApplications(app) if not app.key then app.key = SHA.compute(app.title) end local filename = app.filename or fs.combine(REGISTRY_DIR, app.key) Util.writeTable(filename, app) loadApplications() end function page.editor:loadImage(filename) local s, m = pcall(function() local iconLines = Util.readFile(filename) if not iconLines then error('Must be an NFT image - 3 rows, 8 cols max') end local icon, m = parseIcon(iconLines) if not icon then error(m) end if extSupport then self.form.values.iconExt = iconLines else self.form.values.icon = iconLines end self.form.image:setImage(icon) self.form.image:draw() end) if not s and m then local msg = m:gsub('.*: (.*)', '%1') self.notification:error(msg) end end function page.editor:eventHandler(event) if event.type == 'form_cancel' or event.type == 'cancel' then self:hide() elseif event.type == 'focus_change' then self.statusBar:setStatus(event.focused.help or '') elseif event.type == 'editIcon' then local filename = '/tmp/editing.nft' NFT.save(self.form.image.image or TRANS_ICON, filename) local success = shell.run('pain.lua ' .. filename) self.parent:dirty(true) if success then self:loadImage(filename) end elseif event.type == 'select_file' then self:loadImage(event.file) elseif event.type == 'loadIcon' then self.file_open:show() elseif event.type == 'form_invalid' then self.notification:error(event.message) elseif event.type == 'form_complete' then local values = self.form.values self:hide() self:updateApplications(values) config.currentCategory = values.category Config.update('Overview', config) os.queueEvent('overview_refresh') else return UI.SlideOut.eventHandler(self, event) end return true end local function reload() loadApplications() page:refresh() page:draw() page:sync() end Event.on('overview_shortcut', function(_, app) if not app.key then app.key = SHA.compute(app.title) end local filename = app.filename or fs.combine(REGISTRY_DIR, app.key) if not fs.exists(filename) then Util.writeTable(filename, app) reload() end end) Event.on('overview_refresh', function() reload() end) loadApplications() UI:setPage(page) UI:start()