-- gui.lua -- Лёгкий виджет-фреймворк для OpenComputers поверх pixelgfx.lua. -- Даёт текст (через font.lua), кнопки, чекбоксы, слайдеры, прогресс-бары, -- панели и цикл обработки touch/drag/drop событий с ретина-диффом от -- pixelgfx (перерисовываются только изменившиеся ячейки). -- -- Требует рядом лежащие файлы: pixelgfx.lua, font.lua -- -- Базовое использование: -- local gui = require("gui") -- local screen = gui.newScreen(component.gpu, 1, 1, w, h) -- screen:add(gui.label(4,4,"HELLO",0xffffff)) -- screen:add(gui.button(4,16,40,16,"OK", function() print("click") end)) -- screen:run() local pixelgfx = require("pixelgfx") local font = require("font") local gui = {} gui.font = font -- ---------- текст ---------- -- Рисует текст (в исходных пикселях канваса), возвращает x после последнего -- символа — удобно для последовательной раскладки нескольких элементов. function gui.drawText(canvas, text, px, py, color, scale, spacing) scale = scale or 1 spacing = spacing or 1 local cx = px for i = 1, #text do local ch = text:sub(i, i):upper() local glyph = font[ch] or font[' '] for gy = 1, 5 do local row = glyph[gy] for gx = 1, 3 do if row:sub(gx, gx) == '#' then canvas:fillRect(cx + (gx - 1) * scale, py + (gy - 1) * scale, scale, scale, color) end end end cx = cx + (3 * scale + spacing) end return cx end function gui.textWidth(text, scale, spacing) scale = scale or 1 spacing = spacing or 1 if #text == 0 then return 0 end return #text * (3 * scale + spacing) - spacing end function gui.textHeight(scale) return 5 * (scale or 1) end -- ---------- базовый виджет ---------- local Widget = {} Widget.__index = Widget function Widget.new(t) return setmetatable(t or {}, Widget) end function Widget:contains(px, py) return px >= self.x and px < self.x + self.w and py >= self.y and py < self.y + self.h end function Widget:draw(canvas) end -- ---------- панель (просто заливка + опциональная рамка) ---------- function gui.panel(x, y, w, h, color, opts) opts = opts or {} local self = Widget.new{ x = x, y = y, w = w, h = h, color = color, border = opts.border, borderColor = opts.borderColor, } function self:draw(canvas) canvas:fillRect(self.x, self.y, self.w, self.h, self.color) if self.border then canvas:rectOutline(self.x, self.y, self.w, self.h, self.borderColor or 0xffffff, self.border) end end return self end -- ---------- текстовая метка ---------- function gui.label(x, y, text, color, scale, opts) opts = opts or {} scale = scale or 1 local self = Widget.new{ x = x, y = y, text = text, color = color, scale = scale, spacing = opts.spacing or 1, } self.w = gui.textWidth(text, scale, self.spacing) self.h = gui.textHeight(scale) function self:draw(canvas) gui.drawText(canvas, self.text, self.x, self.y, self.color, self.scale, self.spacing) end function self:setText(t) self.text = t self.w = gui.textWidth(t, self.scale, self.spacing) end function self:setColor(c) self.color = c end return self end -- ---------- кнопка ---------- function gui.button(x, y, w, h, text, onClick, opts) opts = opts or {} local self = Widget.new{ x = x, y = y, w = w, h = h, text = text, onClick = onClick, bg = opts.bg or 0x2a3142, bgActive = opts.bgActive or 0x4ea1ff, fg = opts.fg or 0xe6e6e6, scale = opts.scale or 1, border = opts.border, borderColor = opts.borderColor or 0x3a4356, pressed = false, enabled = true, } function self:draw(canvas) local bg = self.pressed and self.bgActive or self.bg canvas:fillRect(self.x, self.y, self.w, self.h, bg) if self.border then canvas:rectOutline(self.x, self.y, self.w, self.h, self.borderColor, self.border) end local tw = gui.textWidth(self.text, self.scale) local th = gui.textHeight(self.scale) -- Если подпись шире самой кнопки, центрирование увело бы начало -- текста левее self.x (в отрицательные координаты канваса, а то и за -- пределы экрана) — вместо этого прижимаем текст к левому краю кнопки. local tx = math.max(self.x, self.x + math.floor((self.w - tw) / 2)) local ty = self.y + math.floor((self.h - th) / 2) gui.drawText(canvas, self.text, tx, ty, self.fg, self.scale) end function self:onTouch() self.pressed = true if self.onClick then self.onClick(self) end end return self end -- ---------- чекбокс ---------- function gui.checkbox(x, y, size, checked, onToggle, opts) opts = opts or {} local self = Widget.new{ x = x, y = y, w = size, h = size, checked = checked and true or false, onToggle = onToggle, boxColor = opts.boxColor or 0x2a3142, checkColor = opts.checkColor or 0x3ddc84, borderColor = opts.borderColor or 0x5a6272, enabled = true, } function self:draw(canvas) canvas:fillRect(self.x, self.y, self.w, self.h, self.boxColor) canvas:rectOutline(self.x, self.y, self.w, self.h, self.borderColor, 1) if self.checked then canvas:fillRect(self.x + 2, self.y + 2, self.w - 4, self.h - 4, self.checkColor) end end function self:onTouch() self.checked = not self.checked if self.onToggle then self.onToggle(self.checked) end end return self end -- ---------- слайдер (горизонтальный) ---------- function gui.slider(x, y, w, minVal, maxVal, value, onChange, opts) opts = opts or {} local h = opts.h or 6 local self = Widget.new{ x = x, y = y, w = w, h = h, min = minVal, max = maxVal, value = value, onChange = onChange, trackColor = opts.trackColor or 0x2a3142, fillColor = opts.fillColor or 0x4ea1ff, knobColor = opts.knobColor or 0xffffff, enabled = true, } local function setFromPx(px) local t = (px - self.x) / self.w if t < 0 then t = 0 elseif t > 1 then t = 1 end self.value = self.min + t * (self.max - self.min) if self.onChange then self.onChange(self.value) end end function self:draw(canvas) canvas:fillRect(self.x, self.y, self.w, self.h, self.trackColor) local t = (self.value - self.min) / (self.max - self.min) local fw = math.floor(self.w * t) if fw > 0 then canvas:fillRect(self.x, self.y, fw, self.h, self.fillColor) end canvas:fillRect(self.x + fw - 1, self.y - 1, 2, self.h + 2, self.knobColor) end function self:onTouch(px) setFromPx(px) end function self:onDrag(px) setFromPx(px) end return self end -- ---------- прогресс-бар (нет взаимодействия, только индикация) ---------- function gui.progressBar(x, y, w, h, value, maxVal, color, opts) opts = opts or {} local self = Widget.new{ x = x, y = y, w = w, h = h, value = value, max = maxVal, color = color, bg = opts.bg or 0x232838, } function self:draw(canvas) canvas:fillRect(self.x, self.y, self.w, self.h, self.bg) local t = self.value / self.max if t < 0 then t = 0 elseif t > 1 then t = 1 end local fw = math.floor(self.w * t) if fw > 0 then canvas:fillRect(self.x, self.y, fw, self.h, self.color) end end function self:setValue(v) self.value = v end return self end -- ---------- прокси-канвас скролл-панели: клипует и сдвигает координаты ---------- -- Реализует тот же интерфейс (fillRect/rectOutline), что и Canvas из -- pixelgfx, но перед реальной отрисовкой сдвигает координаты на смещение -- прокрутки и обрезает прямоугольник по границам видимой области панели. -- Обычные виджеты (label/button/checkbox/slider/progressBar) рисуют себя -- только через fillRect, поэтому этого достаточно для полной поддержки. local ScrollCanvas = {} ScrollCanvas.__index = ScrollCanvas local function scrollCanvasNew(real, vx, vy, vw, vh, offX, offY) return setmetatable({real = real, vx = vx, vy = vy, vw = vw, vh = vh, offX = offX, offY = offY}, ScrollCanvas) end function ScrollCanvas:fillRect(px, py, w, h, color) local ax, ay = px + self.offX, py + self.offY local x0 = math.max(ax, self.vx) local y0 = math.max(ay, self.vy) local x1 = math.min(ax + w - 1, self.vx + self.vw - 1) local y1 = math.min(ay + h - 1, self.vy + self.vh - 1) if x1 >= x0 and y1 >= y0 then self.real:fillRect(x0, y0, x1 - x0 + 1, y1 - y0 + 1, color) end end function ScrollCanvas:rectOutline(px, py, w, h, color, t) t = t or 1 self:fillRect(px, py, w, t, color) self:fillRect(px, py + h - t, w, t, color) self:fillRect(px, py, t, h, color) self:fillRect(px + w - t, py, t, h, color) end -- Стандартные виджеты их не используют, но интерфейс на всякий случай -- держим полным, чтобы прокси можно было передать в любой draw(). function ScrollCanvas:set(px, py, s) end function ScrollCanvas:blit(rows, palette, px, py) end -- ---------- скролл-панель: контейнер с вертикальной прокруткой ---------- -- Дочерние виджеты используют ЛОКАЛЬНЫЕ координаты (0,0 — верх содержимого, -- ДО прокрутки) — панель сама сдвигает и обрезает их при отрисовке и при -- обработке касаний. Поддерживает: колесо мыши (событие "scroll"), -- протягивание пальцем/курсором по пустому месту внутри панели (drag), -- обычные клики по дочерним виджетам и тонкий скроллбар справа, который -- появляется только когда контент реально не помещается. function gui.scrollPanel(x, y, w, h, opts) opts = opts or {} local self = Widget.new{ x = x, y = y, w = w, h = h, isScrollPanel = true, children = {}, scrollY = 0, contentH = 0, bg = opts.bg, thumbColor = opts.thumbColor or 0x5a6272, trackColor = opts.trackColor or 0x10131a, scrollStep = opts.scrollStep or 6, scrollbarW = opts.scrollbarW or 2, } -- Добавляет дочерний виджет (координаты виджета — локальные, от 0,0). -- Высота контента отслеживается автоматически по самому нижнему виджету. function self:add(widget) table.insert(self.children, widget) self.contentH = math.max(self.contentH, widget.y + widget.h) return widget end -- Полностью очищает содержимое и сбрасывает прокрутку — удобно при -- переключении раздела (см. selectSection в demo.lua). function self:clear() self.children = {} self.contentH = 0 self.scrollY = 0 end function self:setContentHeight(h2) self.contentH = h2 end function self:maxScroll() return math.max(0, self.contentH - self.h) end function self:childAt(localX, localY) for i = #self.children, 1, -1 do local c = self.children[i] if c.visible ~= false and c.enabled ~= false and c.contains and c:contains(localX, localY) then return c end end end function self:draw(canvas) if self.bg then canvas:fillRect(self.x, self.y, self.w, self.h, self.bg) end local maxScroll = self:maxScroll() if self.scrollY > maxScroll then self.scrollY = maxScroll end if self.scrollY < 0 then self.scrollY = 0 end local sc = scrollCanvasNew(canvas, self.x, self.y, self.w, self.h, self.x, self.y - self.scrollY) for _, c in ipairs(self.children) do if c.visible ~= false then c:draw(sc) end end if maxScroll > 0 then local sw = self.scrollbarW local trackX = self.x + self.w - sw canvas:fillRect(trackX, self.y, sw, self.h, self.trackColor) local barH = math.max(4, math.floor(self.h * self.h / self.contentH)) local barY = self.y + math.floor((self.h - barH) * (self.scrollY / maxScroll)) canvas:fillRect(trackX, barY, sw, barH, self.thumbColor) end end -- px,py — координаты касания в пикселях КАНВАСА (уже переведённые -- Screen:toPixel), не локальные. _screen нужен, чтобы отрисовать -- "вспышку" нажатой кнопки-ребёнка (см. gui.button) — Screen сам -- проставляет его при добавлении панели через screen:add(). function self:handleTouch(px, py, button) local localX = px - self.x local localY = py - self.y + self.scrollY local child = self:childAt(localX, localY) self._activeChild = child if child then if child.onTouch then child:onTouch(localX, localY, button) end if child.pressed ~= nil and self._screen then self._screen:draw() os.sleep(0.12) child.pressed = false end else -- Мимо всех дочерних виджетов — значит это протягивание для прокрутки. self._dragStartPy = py self._dragStartScroll = self.scrollY end end function self:handleDrag(px, py) if self._activeChild and self._activeChild.onDrag then local localX = px - self.x local localY = py - self.y + self.scrollY self._activeChild:onDrag(localX, localY) elseif self._dragStartPy then local dy = py - self._dragStartPy local maxScroll = self:maxScroll() local ns = self._dragStartScroll - dy if ns < 0 then ns = 0 elseif ns > maxScroll then ns = maxScroll end self.scrollY = ns end end function self:handleDrop() if self._activeChild and self._activeChild.onDrop then self._activeChild:onDrop() end self._activeChild = nil self._dragStartPy = nil end -- delta > 0 — прокрутка вверх (от себя), < 0 — вниз, как в OpenComputers. function self:onScroll(delta) local maxScroll = self:maxScroll() local ns = self.scrollY - delta * self.scrollStep if ns < 0 then ns = 0 elseif ns > maxScroll then ns = maxScroll end self.scrollY = ns end return self end -- ---------- экран: канвас + список виджетов + событийный цикл ---------- local Screen = {} Screen.__index = Screen -- x,y — позиция канваса на экране в символах; w,h — размер канваса в -- пикселях (как у pixelgfx.new). opts прокидываются в pixelgfx.new -- (opts.buffer, opts.bg). function gui.newScreen(gpu, x, y, w, h, opts) opts = opts or {} local canvas = pixelgfx.new(gpu, x, y, w, h, opts) local self = setmetatable({}, Screen) self.canvas = canvas self.x, self.y, self.w, self.h = x, y, w, h self.widgets = {} self.bg = opts.bg or 0x14161c return self end function Screen:add(widget) table.insert(self.widgets, widget) if widget.isScrollPanel then widget._screen = self end return widget end function Screen:remove(widget) for i, w in ipairs(self.widgets) do if w == widget then table.remove(self.widgets, i); break end end end -- Перевод координат касания (символьные col,row) в пиксельные координаты -- канваса. По вертикали разрешение экрана вдвое выше символьного за счёт -- '▄', но событие touch даёт только целую символьную строку — поэтому -- попадание по верхнему/нижнему пикселю ячейки не различается, берём -- верхний пиксель ячейки. function Screen:toPixel(col, row) local px = col - self.x + 1 local py = (row - self.y) * 2 + 1 return px, py end function Screen:draw() self.canvas:clear(self.bg) for _, w in ipairs(self.widgets) do if w.visible ~= false then w:draw(self.canvas) end end self.canvas:present() end function Screen:widgetAt(px, py) for i = #self.widgets, 1, -1 do local w = self.widgets[i] if w.visible ~= false and w.enabled ~= false and w.contains and w:contains(px, py) then return w end end end function Screen:handleTouch(col, row, button) local px, py = self:toPixel(col, row) local w = self:widgetAt(px, py) if not w then return end self._dragging = w if w.isScrollPanel then w:handleTouch(px, py, button) return w end if w.onTouch then w:onTouch(px, py, button) end -- короткая "вспышка" нажатой кнопки, если у виджета есть pressed if w.pressed ~= nil then self:draw() os.sleep(0.12) w.pressed = false end return w end function Screen:handleDrag(col, row) if not self._dragging then return end local px, py = self:toPixel(col, row) if self._dragging.isScrollPanel then self._dragging:handleDrag(px, py) elseif self._dragging.onDrag then self._dragging:onDrag(px, py) end end function Screen:handleDrop() if self._dragging then if self._dragging.isScrollPanel then self._dragging:handleDrop() elseif self._dragging.onDrop then self._dragging:onDrop() end end self._dragging = nil end -- Колесо мыши: событие "scroll" в OC даёт координаты под курсором и -- направление. Находим виджет под курсором и, если это скролл-панель, -- прокручиваем её; на обычных виджетах колесо ни на что не влияет. function Screen:handleScroll(col, row, delta) local px, py = self:toPixel(col, row) local w = self:widgetAt(px, py) if w and w.isScrollPanel and w.onScroll then w:onScroll(delta) end end -- Событийный цикл. opts.tick — таймаут ожидания события в секундах -- (по истечении вызывается opts.onTick(self), удобно для часов/анимации); -- opts.onKey(self, char, code) — необязательный обработчик key_down; -- opts.shouldStop(self) — необязательная проверка выхода, проверяется -- каждую итерацию. Для программного выхода можно вызвать screen:stop(). -- Не использует table.pack (недоступен в Lua 5.1) — распаковывает -- event.pull напрямую по известным позициям аргументов. function Screen:run(opts) opts = opts or {} local event = require("event") local running = true self.stop = function() running = false end self:draw() while running do local name, _addr, a1, a2, a3 = event.pull(opts.tick or 1) if name == "touch" then self:handleTouch(a1, a2, a3) self:draw() elseif name == "drag" then self:handleDrag(a1, a2) self:draw() elseif name == "drop" then self:handleDrop() self:draw() elseif name == "scroll" then self:handleScroll(a1, a2, a3) self:draw() elseif name == "key_down" and opts.onKey then opts.onKey(self, a1, a2) self:draw() elseif name == nil then if opts.onTick then opts.onTick(self) end self:draw() end if opts.shouldStop and opts.shouldStop(self) then running = false end end end return gui