Skip to content

Instantly share code, notes, and snippets.

@dot-mike
Last active January 17, 2025 19:45
Show Gist options
  • Save dot-mike/e9523f090019c9a535715d19a7dff44f to your computer and use it in GitHub Desktop.
Save dot-mike/e9523f090019c9a535715d19a7dff44f to your computer and use it in GitHub Desktop.
ComputerCraft GUI Framework

ComputerCraft GUI Framework

A flexible GUI framework for ComputerCraft that provides a complete windowing system with tabs, buttons, labels, lists and graphs.

Features

  • Tabbed interface support
  • Multiple UI elements (buttons, labels, lists)
  • Flexible positioning system with anchoring
  • Global and element-specific styling
  • Event handling system

Quick Start Example

local GUI = require("gui")

GUI.init()
local width, height = GUI.getScreenDimensions()

-- Create a tabbed interface
local tabContainer = GUI.createElement("tabs", {
    id = "mainTabs",
    x = 1,
    y = 1,
    width = width,
    height = height
})

local tab1 = tabContainer:addTab("Main")

-- Add a button to the tab
local button = GUI.createElement("button", {
    id = "myButton",
    parent = tab1,
    x = 1,
    y = 1,
    label = "Click me"
})

GUI.refresh()
GUI.run()

Styling

The framework supports both global and element-specific styling:

-- Global styles
GUI.setStyle({
    button = {
        outlineColor = colors.purple,
        fillColor = colors.blue
    },
    label = {
        textColor = colors.blue
    }
})

-- Element-specific style
local button = GUI.createElement("button", {
    id = "customButton",
    style = {
        fillColor = colors.red
    }
})

Element Positioning

Elements can be positioned using:

  • Absolute coordinates (x, y)
  • Anchoring to other elements
  • Alignment within containers

Anchoring Example

local label = GUI.createElement("label", {
    id = "myLabel",
    parent = tab1,
    anchor = button,
    anchorPosition = "below",
    text = "Below the button"
})

Available Elements

Tabs

local tabContainer = GUI.createElement("tabs", {
    id = "mainTabs",
    x = 1,
    y = 1,
    width = width,
    height = height
})
local tab = tabContainer:addTab("Tab Name")

Buttons

local button = GUI.createElement("button", {
    id = "myButton",
    parent = tab,
    label = "Click me",
})

button:onClick(function()
    btn:setState("disabled")
    btn:setLabel("Clicked")
end)

Labels

local label = GUI.createElement("label", {
    id = "myLabel",
    parent = tab,
    text = "Some text"
})

Lists

local list = GUI.createElement("list", {
    id = "myList",
    parent = tab
})
list:addItem("Item 1")
list:onSelect(function(index, item)
    -- Handle selection
})

Graphs

local graph = GUI.createElement("graph", {
    id = "myGraph",
    parent = tab,
    x = 1,
    y = 1,
    width = 30,
    height = 10,
    maxPoints = 100,
    upperLimit = 100,
    lowerLimit = 0,
    displayMode = "line",
    showZeroLine = true
})

graph:addPoint(50)
graph:onUpdate(function(value)
    graph:addPoint(value)
    graph:refresh()
end)

Event Handling

Elements support various events that can be handled:

  • Button clicks: button:onClick(function() end)
  • List selection: list:onSelect(function(index, item) end)
  • State changes: element:setState("disabled")

Screen Management

The framework handles screen updates automatically. Use:

  • GUI.refresh() to update the display
  • GUI.run() to start the event loop

Remember to call GUI.init() at the start of your program and GUI.run() at the end.

local utils = require("gui-utils")
local Element = require("elements/Element")
local Button = setmetatable({}, {__index = Element})
Button.__index = Button
local GUI = nil
local stateListeners = {}
function Button.setGUI(guiRef)
GUI = guiRef
end
function Button.getDefaultStyle()
return {
outlineColor = colors.blue,
fillColor = colors.cyan,
textColor = colors.white,
padding = 1,
fontSize = 1
}
end
local styles = {
default = Button.getDefaultStyle(),
disabled = {
outlineColor = colors.gray,
fillColor = colors.lightGray,
textColor = colors.white,
padding = 2,
fontSize = 1
},
pressed = {
-- Invert the default colors
outlineColor = colors.cyan, -- Swap with fillColor
fillColor = colors.blue, -- Swap with outlineColor
textColor = colors.white,
padding = 2,
fontSize = 1
}
}
local function calculateDimensions(label, style)
local padding = style.padding or 2
local fontSize = style.fontSize or 1
local W, H
local gpu = peripheral.find("tm_gpu")
if gpu then
-- Add extra padding for GPU mode to prevent overlap
W = math.ceil(gpu.getTextLength(label, fontSize)) + (padding * 4)
H = math.ceil(fontSize * 12) -- Increased height for better visibility
else
W = #label + (padding * 2)
H = 3
end
return W, H
end
function Button.new(config)
assert(type(config) == "table", "Config must be a table")
assert(config.label, "Button label is required")
assert(config.id, "Button id is required")
assert(config.gui, "Button gui is required")
local self = Element.new(config.id, config.gui)
setmetatable(self, Button)
self.visible = config.visible ~= nil and config.visible or true
self.backgroundColor = config.backgroundColor or colors.black
self.oldW = 0
self.oldH = 0
self.oldX = 0
self.oldY = 0
self.label = config.label
self.state = config.state or "enabled"
self.style = utils.mergeTables(styles.default, config.style or {})
self.isPressed = false
self.w, self.h = calculateDimensions(self.label, self.style)
self.x = config.x or 1
self.y = config.y or 1
self:adjustPosition(config)
stateListeners[self.id] = {}
-- Add default event handlers
self:on("press", function()
-- Default press behavior
end)
self:on("release", function()
-- Default release behavior
end)
return self
end
-- Add new methods for state listeners
function Button:onStateChanged(callback)
if not self.id then return end
table.insert(stateListeners[self.id], callback)
end
function Button:removeStateListeners()
if self.id then
stateListeners[self.id] = {}
end
end
function Button:getBackgroundColor()
if self.parent and self.parent.getBackgroundColor then
return self.parent.getBackgroundColor()
end
local GUI = require("gui")
return GUI.getBackgroundColor()
end
function Button:draw()
if not self.visible then return end
local gpu = peripheral.find("tm_gpu")
local style
if self.state == "disabled" then
style = styles.disabled
elseif self.isPressed then
style = styles.pressed
else
style = self.style
end
local bg = style.fillColor
local fg = style.textColor
local clearColor = utils.mapColor(self:getBackgroundColor())
if self.oldW > 0 and (self.oldW ~= self.w or self.oldH ~= self.h or self.oldX ~= self.x or self.oldY ~= self.y) then
if gpu then
gpu.filledRectangle(self.oldX,
self.oldY,
self.oldW,
self.oldH,
clearColor
)
else
paintutils.drawFilledBox(self.oldX,
self.oldY,
self.oldX + self.oldW - 1,
self.oldY + self.oldH - 1,
self:getBackgroundColor()
)
end
end
self.oldW = self.w
self.oldH = self.h
self.oldX = self.x
self.oldY = self.y
if gpu then
-- Draw simple button for GPU mode
local textColor = utils.mapColor(fg)
local fillColor = utils.mapColor(bg)
local outlineColor = utils.mapColor(style.outlineColor)
-- Draw main button
gpu.filledRectangle(self.x, self.y, self.w, self.h, outlineColor)
gpu.filledRectangle(self.x + 1, self.y + 1, self.w - 2, self.h - 2, fillColor)
-- Center the text within the button
local textX = self.x + math.floor((self.w - gpu.getTextLength(self.label, style.fontSize)) / 2)
local textY = self.y + math.floor((self.h - (style.fontSize * 9)) / 2)
gpu.drawText(textX, textY, self.label, textColor, fillColor, style.fontSize)
else
-- Draw simple button for terminal mode
paintutils.drawBox(
self.x,
self.y,
self.x + self.w - 1,
self.y + self.h - 1,
style.outlineColor
)
paintutils.drawFilledBox(
self.x + 1,
self.y + 1,
self.x + self.w - 2,
self.y + self.h - 2,
bg
)
-- Draw centered text
local textX = self.x + math.floor((self.w - #self.label) / 2)
local textY = self.y + math.floor(self.h / 2)
term.setCursorPos(textX, textY)
term.setTextColor(fg)
term.write(self.label)
end
end
function Button:onMouseDown(button, x, y)
if self.state == "disabled" then return false end
-- For GPU monitors, handle press and release in one go
if peripheral.find("tm_gpu") then
self.isPressed = true
self:draw()
self:emit("press")
-- Immediate release since we won't get a mouse_up event
self.isPressed = false
self:draw()
self:emit("release")
else
-- Regular computer behavior
self.isPressed = true
self:draw()
self:emit("press")
self.gui.setActiveMouseTarget(self)
end
return true
end
function Button:setState(state)
if state == "enabled" or state == "disabled" then
local oldState = self.state
self.state = state
if self.id and stateListeners[self.id] then
for _, callback in ipairs(stateListeners[self.id]) do
callback(self, oldState, state)
end
end
if GUI then
GUI.refresh()
end
end
end
function Button:getState()
return self.state
end
function Button:getDimensions()
return {
x = self.x,
y = self.y,
width = self.w,
height = self.h,
visible = self.visible
}
end
function Button:setText(text)
self.label = text
local newW, newH = calculateDimensions(self.label, self.style)
if newW ~= self.w or newH ~= self.h then
self.w, self.h = newW, newH
self:draw()
else
self:draw()
end
end
function Button:setVisible(visible)
self.visible = visible
if visible then
self:draw()
end
end
return {
new = Button.new,
styles = styles,
getDefaultStyle = Button.getDefaultStyle,
setGUI = Button.setGUI
}
local utils = require("gui-utils")
local EventEmitter = require("EventEmitter")
local Element = {}
Element.__index = Element
local eventListeners = {}
function Element.new(id, gui)
local self = setmetatable({}, Element)
self.id = id
self.gui = gui
self.x = 1
self.y = 1
self.w = 1
self.h = 1
self.visible = true -- Always start visible by default
self.parent = nil
return self
end
function Element:draw()
error("Draw method must be implemented by child class")
end
function Element:handleEvent(event, ...)
if not self.visible then return false end
if event == "mouse_click" or event == "tm_monitor_touch" then
local button, x, y
if event == "mouse_click" then
button, x, y = ...
else
monitor, x, y = ...
end
if self:isPointInside(x, y) then
return self:onMouseDown(button, x, y)
end
end
return false
end
function Element:isPointInside(x, y)
return x >= self.x and x <= (self.x + self.w - 1) and
y >= self.y and y <= (self.y + self.h - 1)
end
function Element:onMouseDown(button, x, y)
-- Override in child classes
return false
end
function Element:setVisible(visible)
self.visible = visible
if visible then
self:draw()
end
end
function Element:getBackgroundColor()
if self.parent and self.parent.getBackgroundColor then
return self.parent.getBackgroundColor()
end
return self.gui.getBackgroundColor()
end
function Element:getDimensions()
return {
x = self.x,
y = self.y,
width = self.w,
height = self.h,
visible = self.visible
}
end
function Element:adjustPosition(config)
local area
if self.parent and self.parent.contentArea then
-- Account for parent's border if it exists
local borderOffset = (self.parent.style and self.parent.style.border) and 1 or 0
area = {
x = self.parent.contentArea.x + borderOffset,
y = self.parent.contentArea.y + borderOffset,
width = self.parent.contentArea.width - (borderOffset * 2),
height = self.parent.contentArea.height - (borderOffset * 2)
}
else
-- Use screen dimensions if no parent
local width, height = self.gui.getScreenDimensions()
area = {
x = 1,
y = 1,
width = width,
height = height
}
end
-- Constrain dimensions to available area
self.w = math.min(self.w, area.width)
self.h = math.min(self.h, area.height)
-- Store original x,y as relative positions if provided
if type(config.x) == "number" then
self.x = area.x + (config.x - 1)
end
if type(config.y) == "number" then
self.y = area.y + (config.y - 1)
end
if config.align then
if config.align == "center" then
self.x = area.x + math.floor((area.width - self.w) / 2)
elseif config.align == "right" then
self.x = area.x + (area.width - self.w)
elseif config.align == "left" then
self.x = area.x
end
end
if config.verticalAlign then
if config.verticalAlign == "middle" then
self.y = area.y + math.floor((area.height - self.h) / 2)
elseif config.verticalAlign == "bottom" then
self.y = area.y + (area.height - self.h)
elseif config.verticalAlign == "top" then
self.y = area.y
end
end
-- Ensure x position stays within bounds
self.x = math.max(area.x, math.min(area.x + area.width - self.w, self.x))
if config.anchor then
local ref = config.anchor
if ref.parentTab and config.parent and ref.parentTab ~= config.parent then
error("Cannot anchor elements to elements in different tabs")
end
local position = config.anchorPosition or "right"
-- Account for element's own border if it exists
local borderOffset = (self.style and self.style.border) and 1 or 0
if position == "right" then
self.x = ref.x + ref.w + 1 + borderOffset
self.y = ref.y
elseif position == "left" then
self.x = ref.x - self.w - 1 - borderOffset
self.y = ref.y
elseif position == "above" then
self.x = ref.x
if config.align == "right" then
self.x = area.x + (area.width - self.w)
end
self.y = ref.y - self.h - borderOffset
elseif position == "below" then
self.x = ref.x
if config.align == "right" then
self.x = area.x + (area.width - self.w)
end
self.y = ref.y + ref.h + 1 + borderOffset
end
end
end
function Element:on(eventName, callback)
if not eventListeners[self.id] then
eventListeners[self.id] = {}
end
if not eventListeners[self.id][eventName] then
eventListeners[self.id][eventName] = {}
end
table.insert(eventListeners[self.id][eventName], callback)
end
function Element:emit(eventName, ...)
if eventListeners[self.id] and eventListeners[self.id][eventName] then
for _, callback in ipairs(eventListeners[self.id][eventName]) do
callback(...)
end
end
end
function Element:onClick(callback)
return self.gui.on("click", function(clickedElement, ...)
if clickedElement == self then
callback(self, ...)
end
end)
end
return Element
local EventEmitter = {}
EventEmitter.__index = EventEmitter
function EventEmitter.new()
local self = setmetatable({}, EventEmitter)
self.eventListeners = {}
return self
end
function EventEmitter:emit(eventName, element, ...)
if self.eventListeners[eventName] then
for _, callback in ipairs(self.eventListeners[eventName]) do
callback(element or self, ...)
end
end
end
function EventEmitter:on(eventName, callback)
self.eventListeners[eventName] = self.eventListeners[eventName] or {}
table.insert(self.eventListeners[eventName], callback)
return #self.eventListeners[eventName]
end
function EventEmitter:removeListener(eventName, index)
if self.eventListeners[eventName] then
table.remove(self.eventListeners[eventName], index)
if #self.eventListeners[eventName] == 0 then
self.eventListeners[eventName] = nil
end
end
end
return EventEmitter
local GUI = require("gui")
GUI.init()
local width, height = GUI.getScreenDimensions()
-- Define global styles (these override element defaults)
GUI.setStyle({
button = {
outlineColor = colors.purple,
fillColor = colors.blue
},
label = {
textColor = colors.blue
},
tabs = {
activeTabColor = colors.blue
}
})
local counter = 0
local counterLabel
local tabContainer = GUI.createElement("tabs", {
id = "mainTabs",
x = 1,
y = 1,
width = width,
height = height
})
-- Add a tab with the label "Counter"
local tab1 = tabContainer:addTab("Counter")
-- Add a tab with the label "Settings"
local tab2 = tabContainer:addTab("Settings")
-- Add a tab with the label "Tab 3"
local tab3 = tabContainer:addTab("Graph1")
-- Add a tab with the label "Tab 3"
local tab4 = tabContainer:addTab("Graph2")
-- Add a tab with the label "Sine Wave"
local tab5 = tabContainer:addTab("Sine Wave")
-- Add a tab with the label "Square Wave"
local tab6 = tabContainer:addTab("Square Wave")
-- Add a tab with the label "Triangle Wave"
local tab7 = tabContainer:addTab("Triangle Wave")
-- tab1
local btn1 = GUI.createElement("button", {
id = "counterButton",
parent = tab1, -- Add to the first tab
x = 1,
y = 1,
label = "Click me",
style = {
fillColor = colors.red -- Override both default and global styles
}
})
btn1:onClick(function()
counter = counter + 1
counterLabel:setText("Count: " .. counter)
GUI.refresh()
end)
counterLabel = GUI.createElement("label", {
id = "counterLabel",
parent = tab1,
anchor = btn1,
anchorPosition = "below",
text = "Count: 0",
style = {
textColor = colors.green -- Override global style
}
})
local myList = GUI.createElement("list", {
id = "myList",
parent = tab1,
anchor = counterLabel,
anchorPosition = "below",
})
myList:addItem("Item 1")
myList:addItem("Item 2")
local selectedItemLabel = GUI.createElement("label", {
id = "selectedItemLabel",
parent = tab1,
anchor = myList,
anchorPosition = "right",
text = "Selected item: None"
})
myList:onSelect(function(selectedIndex, selectedItem)
selectedItemLabel:setText("Selected item: " .. selectedItem)
GUI.refresh()
end)
local btn3 = GUI.createElement("button", {
id = "testButton",
parent = tab1,
align = "right", -- Align to the right of the tab
verticalAlign = "center", -- Align to the center of the tab
label = "Click me",
style = {
fillColor = colors.red -- Override both default and global styles
}
})
-- tab2
local btn2 = GUI.createElement("button", {
id = "settingsButton",
parent = tab2,
label = "Click me once",
})
btn2:onClick(function()
btn2:setText("Clicked!")
btn2:setState("disabled")
end)
local settingstext = GUI.createElement("label", {
id = "settingstext",
parent = tab2,
text = "Settings tab content with really long text that should wrap around to the next line",
anchor = btn2,
anchorPosition = "below"
})
local settingstext2 = GUI.createElement("label", {
id = "settingstext2",
parent = tab2,
text = "Settings tab content 2",
anchor = settingstext,
anchorPosition = "below"
})
-- tab3
local graph = GUI.createElement("graph", {
id = "myGraph",
parent = tab3,
x = 2,
y = 2,
width = width - 4, -- Leave some padding
height = math.floor(height * 0.6), -- Use 60% of height
maxPoints = 30,
upperLimit = 100,
lowerLimit = 0,
displayMode = "bar",
showZeroLine = false, -- Disable zero line for bar graph
style = {
borderColor = colors.lightBlue,
graphColor = colors.lime,
backgroundColor = colors.black
}
})
-- Add update button
local updateButton = GUI.createElement("button", {
id = "updateGraph",
parent = tab3,
anchor = graph,
anchorPosition = "below",
label = "Add Data Point",
})
updateButton:onClick(function()
graph:addPoint(math.random(0, 100))
GUI.refresh()
end)
-- Add some initial data
for i = 1, 10 do
graph:addPoint(math.random(0, 100))
end
-- tab4
local graph2 = GUI.createElement("graph", {
id = "myGraph2",
parent = tab4,
x = 2,
y = 2,
width = width - 4, -- Leave some padding
height = math.floor(height * 0.6), -- Use 60% of height
maxPoints = 100,
upperLimit = 100,
lowerLimit = -100, -- Set to negative for bipolar data
displayMode = "line", -- Ensure it's a line graph
showZeroLine = true, -- Enable zero line for line graph
style = {
borderColor = colors.lightBlue,
graphColor = colors.lime,
backgroundColor = colors.black
}
})
local angle = 0
-- tab5 - Sine Wave
local sineGraph = GUI.createElement("graph", {
id = "sineGraph",
parent = tab5,
x = 2,
y = 2,
width = width - 4,
height = math.floor(height * 0.6),
maxPoints = 100,
upperLimit = 100,
lowerLimit = -100,
displayMode = "line",
showZeroLine = true,
style = {
borderColor = colors.lightBlue,
graphColor = colors.yellow,
backgroundColor = colors.black,
negativeGraphColor = colors.red
}
})
local angle = 0
-- tab6 - Square Wave
local squareGraph = GUI.createElement("graph", {
id = "squareGraph",
parent = tab6,
x = 2,
y = 2,
width = width - 4,
height = math.floor(height * 0.6),
maxPoints = 50,
upperLimit = 100,
lowerLimit = -100,
displayMode = "line",
showZeroLine = true,
style = {
borderColor = colors.lightBlue,
graphColor = colors.green,
backgroundColor = colors.black,
negativeGraphColor = colors.red
}
})
local squareValue = 100
local isHigh = true
-- tab7 - Triangle Wave
local triangleGraph = GUI.createElement("graph", {
id = "triangleGraph",
parent = tab7,
x = 2,
y = 2,
width = width - 4,
height = math.floor(height * 0.6),
maxPoints = 100,
upperLimit = 100,
lowerLimit = -100,
displayMode = "line",
showZeroLine = true,
style = {
borderColor = colors.lightBlue,
graphColor = colors.orange,
backgroundColor = colors.black,
negativeGraphColor = colors.red
}
})
local triangleValue = -100
local triDirection = 1
-- Add parallel.waitForAll to run both the updater and GUI
parallel.waitForAll(
function()
while true do
local sineValue = math.sin(angle) * 100
graph2:addPoint(sineValue)
angle = angle + math.pi / 16 -- Increment angle for sine wave
GUI.refresh()
sleep(0.5) -- Update interval in seconds
end
end,
function()
while true do
local sineValue = math.sin(angle) * 100
sineGraph:addPoint(sineValue)
angle = angle + math.pi / 16
GUI.refresh()
sleep(0.05)
end
end,
function()
while true do
squareGraph:addPoint(squareValue)
if isHigh then
squareValue = -90
else
squareValue = 90
end
isHigh = not isHigh
GUI.refresh()
sleep(0.5)
end
end,
function()
while true do
triangleGraph:addPoint(triangleValue)
if triangleValue >= 100 then
triDirection = -1
elseif triangleValue <= -100 then
triDirection = 1
end
triangleValue = triangleValue + triDirection * 10
GUI.refresh()
sleep(0.1)
end
end,
function()
GUI.run()
end
)
local utils = require("gui-utils")
local Element = require("elements/Element")
local Graph = setmetatable({}, {__index = Element})
Graph.__index = Graph
local GUI = nil
function Graph.getDefaultStyle()
return {
borderColor = colors.lightGray,
graphColor = colors.blue,
negativeGraphColor = colors.red,
backgroundColor = colors.black,
borderChars = {
horizontal = "─",
vertical = "│",
topLeft = "┌",
topRight = "┐",
bottomLeft = "└",
bottomRight = "┘"
}
}
end
local styles = {
default = Graph.getDefaultStyle()
}
function Graph.new(config)
assert(type(config) == "table", "Config must be a table")
local self = Element.new(config.id, config.gui)
setmetatable(self, Graph)
self.data = {}
self.maxPoints = config.maxPoints or 50
self.upperLimit = config.upperLimit or 100
self.lowerLimit = config.lowerLimit or 0
self.displayMode = config.displayMode or "line" -- "line" or "bar"
self.style = utils.mergeTables(styles.default, config.style or {})
self.showZeroLine = config.showZeroLine ~= false -- Default to true if not specified
self.parent = config.parent
self.x = config.x or 1
self.y = config.y or 1
self.width = config.width
self.height = config.height
return self
end
function Graph:getDimensions()
return {
x = self.x,
y = self.y,
width = self.width,
height = self.height,
visible = self.visible
}
end
function Graph:setPosition(x, y)
self.x = x
self.y = y
end
function Graph:getSize()
return self.width, self.height
end
function Graph:addPoint(value)
table.insert(self.data, value)
if #self.data > self.maxPoints then
table.remove(self.data, 1)
end
end
function Graph:setUpperLimit(value)
self.upperLimit = value
end
function Graph:setLowerLimit(value)
self.lowerLimit = value
end
function Graph:setPointLimit(value)
self.maxPoints = value
end
function Graph:setDisplayMode(mode)
if mode == "line" or mode == "bar" then
self.displayMode = mode
end
end
function Graph:getPosition()
return self.x, self.y
end
function Graph:getContentArea()
return {
x = self.x,
y = self.y + self.height + 1, -- Add +1 to ensure spacing between graph and anchored elements
width = self.width,
height = 1
}
end
function Graph:getBackgroundColor()
if self.parent and self.parent.getBackgroundColor then
return self.parent.getBackgroundColor()
end
return colors.black
end
function Graph:draw()
local w, h = self.width or 30, self.height or 10
local range = self.upperLimit - self.lowerLimit
local gpu = self.gui.gpu
if gpu then
-- Draw border with GPU
local borderColor = utils.mapColor(self.style.borderColor)
local bgColor = utils.mapColor(self.style.backgroundColor)
local graphColor = utils.mapColor(self.style.graphColor)
local negativeGraphColor = utils.mapColor(self.style.negativeGraphColor)
-- Clear background
gpu.filledRectangle(
self.x,
self.y,
w,
h,
bgColor
)
-- Draw border
gpu.rectangle(
self.x,
self.y,
w,
h,
borderColor
)
-- Calculate zeroY based on showZeroLine
local zeroY
if self.showZeroLine then
zeroY = self.y + math.floor(self.height / 2)
gpu.filledRectangle(
self.x,
zeroY,
self.width,
1,
borderColor
)
else
zeroY = self.y + self.height - 1 -- Set to bottom of the graph area
end
-- Draw data points
for i = 1, #self.data do
local x = math.floor((i / #self.data) * (w - 2)) + self.x
local valuePercent = (self.data[i] - self.lowerLimit) / range
if self.displayMode == "bar" then
if self.showZeroLine then
if self.data[i] >= 0 then
-- Positive value: draw bar above zero line
local barHeight = math.floor((self.data[i] / range) * (h / 2))
gpu.filledRectangle(
x,
zeroY - barHeight,
1, -- width of bar
barHeight, -- height of bar
graphColor
)
else
-- Negative value: draw bar below zero line
local barHeight = math.floor((math.abs(self.data[i]) / range) * (h / 2))
gpu.filledRectangle(
x,
zeroY,
1, -- width of bar
barHeight, -- height of bar
negativeGraphColor
)
end
else
-- No zero line: draw bar from bottom
local barHeight = math.floor((self.data[i] / range) * h)
gpu.filledRectangle(
x,
zeroY - barHeight,
1, -- width of bar
barHeight, -- height of bar
graphColor
)
end
else -- line mode
local currentGraphColor = graphColor
if self.data[i] < 0 then
currentGraphColor = negativeGraphColor
end
-- Draw point using small rectangle
local y = math.floor((1 - valuePercent) * (h - 2)) + self.y
gpu.filledRectangle(
x,
y,
1, -- point width
1, -- point height
currentGraphColor
)
end
end
else
-- Fallback to simple ASCII border for non-GPU mode
for x = 1, w do
term.setCursorPos(self.x + x - 1, self.y)
term.write("-")
term.setCursorPos(self.x + x - 1, self.y + h - 1)
term.write("-")
end
for y = 1, h do
term.setCursorPos(self.x, self.y + y - 1)
term.write("|")
term.setCursorPos(self.x + w - 1, self.y + y - 1)
term.write("|")
end
-- Calculate zeroY based on showZeroLine
local zeroY
if self.showZeroLine then
zeroY = self.y + math.floor(self.height / 2)
paintutils.drawLine(self.x, zeroY, self.x + w - 1, zeroY, colors.lightGray)
else
zeroY = self.y + self.height - 1 -- Set to bottom of the graph area
end
-- Draw data points
local graphColor = self.style.graphColor
local negativeGraphColor = self.style.negativeGraphColor
for i = 1, #self.data do
local x = math.floor((i / #self.data) * (w - 2)) + self.x
local valuePercent = (self.data[i] - self.lowerLimit) / range
if self.displayMode == "bar" then
if self.showZeroLine then
if self.data[i] >= 0 then
-- Positive value: draw bar above zero line
local barHeight = math.floor((self.data[i] / range) * (h / 2))
for py = zeroY - barHeight, zeroY - 1 do
term.setCursorPos(x, py)
term.write("#")
end
else
-- Negative value: draw bar below zero line
local barHeight = math.floor((math.abs(self.data[i]) / range) * (h / 2))
term.setTextColor(negativeGraphColor)
for py = zeroY, zeroY + barHeight - 1 do
term.setCursorPos(x, py)
term.write("#")
end
term.setTextColor(graphColor)
end
else
-- No zero line: draw bar from bottom
local barHeight = math.floor((self.data[i] / range) * h)
for py = zeroY - barHeight, zeroY - 1 do
term.setCursorPos(x, py)
term.write("#")
end
end
else -- line mode
local currentGraphColor = graphColor
if self.data[i] < 0 then
currentGraphColor = negativeGraphColor
end
local y = math.floor((1 - valuePercent) * (h - 2)) + self.y
term.setTextColor(currentGraphColor)
term.setCursorPos(x, y)
term.write("*")
term.setTextColor(graphColor)
end
end
end
end
return Graph
local VERSION = "0.1"
local colorMap = {
[colors.black] = 0x111111,
[colors.white] = 0xF0F0F0,
[colors.orange] = 0xF2B233,
[colors.magenta] = 0xE57FD8,
[colors.lightBlue] = 0x99B2F2,
[colors.yellow] = 0xDEDE6C,
[colors.lime] = 0x7FCC19,
[colors.pink] = 0xF2B2CC,
[colors.gray] = 0x4C4C4C,
[colors.lightGray] = 0x999999,
[colors.cyan] = 0x4C99B2,
[colors.purple] = 0xB266E5,
[colors.blue] = 0x3366CC,
[colors.brown] = 0x7F664c,
[colors.green] = 0x57A64E,
[colors.red] = 0xCC4C4C
}
local function mapColor(color)
return colorMap[color]
end
local function mergeTables(...)
local result = {}
for i = 1, select("#", ...) do
local t = select(i, ...)
if type(t) == "table" then
for k, v in pairs(t) do
result[k] = v
end
end
end
return result
end
local function padString(str, length)
str = tostring(str)
if #str > length then
return str:sub(1, length)
else
return str .. string.rep(" ", length - #str)
end
end
return {
mergeTables = mergeTables,
mapColor = mapColor,
padString = padString
}
-- Gui Framework for ComputerCraft
-- Version 1.0
local Button = require("elements/Button")
local Label = require("elements/Label")
local Tabs = require("elements/Tabs")
local List = require("elements/List")
local Graph = require("elements/Graph")
local ProgressBar = require("elements/ProgressBar")
local utils = require("gui-utils")
local EventEmitter = require("EventEmitter")
local elementTypes = {
button = Button,
label = Label,
tabs = Tabs,
list = List,
graph = Graph,
progressbar = ProgressBar
}
local GUI = {
elements = {},
rootContainer = nil,
gpu = peripheral.find("tm_gpu"),
backgroundColor = colors.black,
mergedStyles = {
button = {},
label = {},
tabs = {},
list = {},
graph = {},
progressbar = {}
},
screenWidth = 0,
screenHeight = 0,
events = EventEmitter.new(),
activeMouseTarget = nil
}
Tabs.setGUI(GUI)
Button.setGUI(GUI)
function GUI.initializeStyles()
for type, constructor in pairs(elementTypes) do
GUI.mergedStyles[type] = constructor.getDefaultStyle()
end
end
function GUI.setStyle(styles)
for elementType, style in pairs(styles) do
GUI.mergedStyles[elementType] = utils.mergeTables(
elementTypes[elementType].getDefaultStyle(),
GUI.mergedStyles[elementType],
style
)
end
end
function GUI.init()
GUI.initializeStyles()
if GUI.gpu then
GUI.gpu.refreshSize()
GUI.gpu.setSize(64)
local w, h = GUI.gpu.getSize()
GUI.screenWidth = w
GUI.screenHeight = h
GUI.gpu.fill(utils.mapColor(GUI.backgroundColor))
GUI.gpu.sync()
else
GUI.screenWidth, GUI.screenHeight = term.getSize()
term.setBackgroundColor(GUI.backgroundColor)
term.clear()
end
-- Add tab change handler
GUI.events:on("tabChange", function(tabContainer, newTab, oldTab)
GUI.refresh()
end)
end
function GUI.getBackgroundColor()
return GUI.backgroundColor
end
function GUI.getScreenDimensions()
return GUI.screenWidth, GUI.screenHeight
end
function GUI.createElement(elementType, config)
local elementConstructor = elementTypes[elementType]
if not elementConstructor then
error("Unknown element type: " .. elementType)
end
config.gui = GUI
config.style = utils.mergeTables(
GUI.mergedStyles[elementType],
config.style or {}
)
local element = elementConstructor.new(config)
GUI.elements[config.id] = element
if config.parent and type(config.parent.adjustElementPosition) == "function" then
config.parent:adjustElementPosition(element)
end
return element
end
function GUI.removeElement(id)
GUI.elements[id] = nil
end
function GUI.setActiveMouseTarget(element)
GUI.activeMouseTarget = element
end
function GUI.getActiveMouseTarget()
return GUI.activeMouseTarget
end
function GUI.clearActiveMouseTarget()
GUI.activeMouseTarget = nil
end
function GUI.handleEvent(event, ...)
local args = {...}
if event == "mouse_click" then
local button, x, y = ...
for _, element in pairs(GUI.elements) do
if element:handleEvent(event, button, x, y) then
break
end
end
elseif event == "tm_monitor_touch" then
local monitor, x, y = ...
-- Handle touch events like mouse clicks
for _, element in pairs(GUI.elements) do
if element:handleEvent(event, monitor, x, y) then
break
end
end
elseif event == "mouse_up" then
-- Only handle mouse_up for regular computers
if not GUI.gpu and GUI.activeMouseTarget then
GUI.activeMouseTarget:emit("release")
GUI.activeMouseTarget = nil
end
end
for _, element in pairs(GUI.elements) do
if element.visible then
local handled = element:handleEvent(event, ...)
if handled then
if (event == "mouse_click" or event == "tm_monitor_touch") then
GUI.events:emit("click", element, ...)
end
return true
end
end
end
return false
end
-- Add event method wrappers
function GUI.on(eventName, callback)
return GUI.events:on(eventName, callback)
end
function GUI.emit(eventName, ...)
return GUI.events:emit(eventName, ...)
end
function GUI.removeListener(eventName, index)
return GUI.events:removeListener(eventName, index)
end
function GUI.refresh()
if GUI.gpu then
GUI.gpu.fill(utils.mapColor(GUI.backgroundColor))
else
term.setBackgroundColor(GUI.backgroundColor)
term.clear()
end
-- Draw tab containers first
for _, element in pairs(GUI.elements) do
if element.tabs then
element:draw()
end
end
-- Draw other elements only if they're in the active tab
for _, element in pairs(GUI.elements) do
if not element.tabs then
local shouldDraw = element.visible
-- Check if element belongs to a tab and if it's in the active tab
if element.parent and element.parent.parentTab then
shouldDraw = shouldDraw and
element.parent.parentTab.activeTab == element.parent
end
if shouldDraw then
element:draw()
end
end
end
if GUI.gpu then
GUI.gpu.sync()
end
end
function GUI.run()
while true do
local event = {os.pullEvent()}
if event[1] == "terminate" then
break
end
GUI.handleEvent(table.unpack(event))
end
end
return GUI
local utils = require("gui-utils")
local Element = require("elements/Element")
local Label = setmetatable({}, {__index = Element})
Label.__index = Label
function Label.getDefaultStyle()
return {
textColor = colors.white,
backgroundColor = colors.black,
padding = 1,
fontSize = 1 -- Add fontSize property
}
end
local styles = {
default = Label.getDefaultStyle()
}
function Label:splitTextIntoLines()
local lines = {}
local words = {}
local currentWord = ""
-- Split text into words
for i = 1, #self.text do
local char = self.text:sub(i, i)
if char == " " then
if currentWord ~= "" then
table.insert(words, currentWord)
currentWord = ""
end
else
currentWord = currentWord .. char
end
end
if currentWord ~= "" then
table.insert(words, currentWord)
end
-- Calculate available width considering parent container
local maxWidth
if self.parent and self.parent.contentArea then
maxWidth = self.parent.contentArea.width - (self.style.padding * 2)
else
maxWidth = self.gui.screenWidth - self.x - (self.style.padding * 2)
end
local currentLine = ""
for _, word in ipairs(words) do
local testLine = currentLine == "" and word or (currentLine .. " " .. word)
local lineWidth = self.gui.gpu and self.gui.gpu.getTextLength(testLine, self.style.fontSize) or #testLine
if lineWidth <= maxWidth then
currentLine = testLine
else
if currentLine ~= "" then
table.insert(lines, currentLine)
end
currentLine = word
end
end
if currentLine ~= "" then
table.insert(lines, currentLine)
end
return lines
end
function Label.new(config)
assert(type(config) == "table", "Config must be a table")
assert(config.text, "Label text is required")
assert(config.id, "Label id is required")
assert(config.gui, "Label gui is required")
local self = Element.new(config.id, config.gui)
setmetatable(self, Label)
self.visible = config.visible ~= nil and config.visible or true
self.text = config.text
self.style = utils.mergeTables(styles.default, config.style or {})
self.parent = config.parent
-- Store anchoring information
self.anchor = config.anchor
self.anchorPosition = config.anchorPosition or "below"
-- Store initial position
self.x = config.x or 1
self.y = config.y or 1
-- If we have an anchor, adjust position based on it
if self.anchor then
local anchorDims = self.anchor:getDimensions()
if anchorDims then
if self.anchorPosition == "below" then
self.y = anchorDims.y + anchorDims.height + 1
self.x = anchorDims.x
elseif self.anchorPosition == "right" then
self.x = anchorDims.x + anchorDims.width + 1
self.y = anchorDims.y
end
end
end
return self
end
-- Update getDimensions to account for multiple lines
function Label:getDimensions()
local gpu = self.gui.gpu
local fontSize = self.style.fontSize or 1
local lines = self:splitTextIntoLines()
-- Find the widest line
local maxWidth = 0
for _, line in ipairs(lines) do
local lineWidth = gpu and gpu.getTextLength(line, fontSize) or #line
maxWidth = math.max(maxWidth, lineWidth)
end
-- Calculate total height based on number of lines
local height
if gpu then
height = math.ceil(fontSize * 12) * #lines
else
height = #lines
end
return {
x = self.x,
y = self.y,
width = maxWidth,
height = height,
visible = self.visible
}
end
function Label:updateDimensions()
local lines = self:splitTextIntoLines()
local padding = self.style.padding
local fontSize = self.style.fontSize or 1
-- Calculate maximum line width
local maxWidth = 0
for _, line in ipairs(lines) do
local lineWidth = self.gui.gpu and self.gui.gpu.getTextLength(line, fontSize) or #line
maxWidth = math.max(maxWidth, lineWidth)
end
end
function Label:updatePosition()
if self.anchor then
local anchorDims = self.anchor:getDimensions()
if anchorDims then
if self.anchorPosition == "below" then
self.y = anchorDims.y + anchorDims.height + 1
self.x = anchorDims.x
elseif self.anchorPosition == "right" then
self.x = anchorDims.x + anchorDims.width + 1
self.y = anchorDims.y
end
end
end
end
function Label:draw()
if not self.visible then return end
local gpu = self.gui.gpu
local bg = self.style.backgroundColor
local fg = self.style.textColor
local fontSize = self.style.fontSize or 1
local lines = self:splitTextIntoLines()
if gpu then
-- Draw each line with proper vertical spacing
for i, line in ipairs(lines) do
local lineY = self.y + ((i-1) * math.ceil(fontSize * 12))
gpu.drawText(
self.x,
lineY,
line,
utils.mapColor(fg),
utils.mapColor(bg),
fontSize
)
end
else
-- Terminal mode
for i, line in ipairs(lines) do
term.setCursorPos(self.x, self.y + (i-1))
term.setTextColor(fg)
term.setBackgroundColor(bg)
term.write(line)
end
end
end
function Label:handleEvent()
return false
end
function Label:setText(text)
self.text = text
self:updateDimensions()
self:updatePosition()
end
return {
new = Label.new,
styles = styles,
getDefaultStyle = Label.getDefaultStyle
}
local Element = require("elements/Element")
local utils = require("gui-utils")
local List = setmetatable({}, { __index = Element })
List.__index = List
function List.getDefaultStyle()
return {
textColor = colors.white,
backgroundColor = colors.gray,
selectedTextColor = colors.black,
selectedBackgroundColor = colors.lightGray,
border = true,
borderColor = colors.lightGray,
fontSize = 1,
padding = 2 -- Add padding property
}
end
function List:calculateDimensions()
local fontSize = self.style.fontSize or 1
local lineHeight = math.ceil(fontSize * 9)
local padding = (self.style.padding or 2) * 2 -- Total padding (left + right)
local borderOffset = (self.style and self.style.border) and 2 or 0
-- Calculate maximum width based on content
local maxWidth = 0
for _, item in ipairs(self.items) do
local textWidth = self.gui.gpu and
self.gui.gpu.getTextLength(tostring(item), fontSize) or
#tostring(item)
maxWidth = math.max(maxWidth, textWidth)
end
-- Add padding to width
maxWidth = maxWidth + padding
-- Calculate height based on number of items
local contentHeight = #self.items * lineHeight
-- Update dimensions
self.contentW = maxWidth
self.contentH = #self.items
-- Set total dimensions including borders
self.w = self.contentW + borderOffset
self.h = (self.contentH * lineHeight) + borderOffset
return {
width = self.w,
height = self.h,
contentWidth = self.contentW,
contentHeight = self.contentH,
lineHeight = lineHeight
}
end
function List.new(config)
local self = setmetatable(Element.new(config.id, config.gui), List)
self.items = {}
self.selectedIndex = 0
self.style = config.style
self.parent = config.parent
-- Set initial dimensions to minimum values
self.contentW = 10 -- Minimum width
self.contentH = 1 -- Minimum height
self.w = self.contentW
self.h = self.contentH
-- Get available area dimensions
local area
if self.parent and self.parent.contentArea then
area = {
width = self.parent.contentArea.width,
height = self.parent.contentArea.height
}
else
local width, height = config.gui.getScreenDimensions()
area = {
width = width,
height = height
}
end
local borderOffset = (self.style and self.style.border) and 2 or 0
local fontSize = self.style.fontSize or 1
local lineHeight = math.ceil(fontSize * 9)
-- Handle anchoring and positioning
if config.anchor then
local anchorDims = config.anchor:getDimensions()
if anchorDims then
if config.anchorPosition == "below" then
-- Add extra spacing when anchored below a Label
local extraSpacing = 2
config.x = anchorDims.x
config.y = anchorDims.y + anchorDims.height + extraSpacing
elseif config.anchorPosition == "right" then
config.x = anchorDims.x + anchorDims.width + 2
config.y = anchorDims.y
end
end
end
-- Set positions
self.x = config.x or 1
self.y = config.y or 1
-- Store the actual content dimensions
self.contentW = math.min(config.w or 20, area.width - borderOffset)
self.contentH = math.min(config.h or 5, math.floor((area.height - borderOffset) / lineHeight))
-- Set total dimensions including borders
self.w = self.contentW + borderOffset
self.h = (self.contentH * lineHeight) + borderOffset
-- Additional height adjustment for parent bounds
if self.parent and self.parent.contentArea then
local maxHeight = self.parent.contentArea.height - (self.y - self.parent.contentArea.y) - borderOffset
self.h = math.min(self.h, maxHeight)
end
self.visible = config.visible ~= false
return self
end
function List:addItem(item)
table.insert(self.items, item)
self:calculateDimensions() -- Recalculate dimensions when adding items
self:draw()
return #self.items
end
function List:removeItem(index)
if index > 0 and index <= #self.items then
table.remove(self.items, index)
if self.selectedIndex == index then
self.selectedIndex = 0
elseif self.selectedIndex > index then
self.selectedIndex = self.selectedIndex - 1
end
self:draw()
return true
end
return false
end
function List:editItem(index, newValue)
if index > 0 and index <= #self.items then
self.items[index] = newValue
self:draw()
return true
end
return false
end
function List:getItem(index)
return self.items[index]
end
function List:getItemCount()
return #self.items
end
function List:selectItem(index)
if index >= 0 and index <= #self.items then
local oldIndex = self.selectedIndex
self.selectedIndex = index
self:draw()
self:emit("list_select", index, self.items[index])
return true
end
return false
end
function List:onSelect(callback)
self:on("list_select", callback)
end
function List:clear()
self.items = {}
self.selectedIndex = 0
self:draw()
end
function List:getItemIndex()
return self.selectedIndex
end
function List:draw()
if not self.visible then return end
local gpu = self.gui.gpu
local bgColor = utils.mapColor(self.style.backgroundColor)
local selectedBgColor = utils.mapColor(self.style.selectedBackgroundColor)
if gpu then
local dims = self:calculateDimensions()
local fontSize = self.style.fontSize or 1
local padding = self.style.padding or 2
local borderOffset = (self.style and self.style.border) and 1 or 0
-- Calculate content area (excluding borders)
local contentX = self.x + borderOffset + padding
local contentY = self.y + borderOffset
-- Clear entire area first
gpu.filledRectangle(
self.x - 1,
self.y - 1,
self.w + 2,
self.h + 2,
bgColor
)
-- Draw border if enabled
if self.style.border then
local borderColor = utils.mapColor(self.style.borderColor)
gpu.filledRectangle(self.x - 1, self.y - 1, self.w + 2, 1, borderColor) -- top
gpu.filledRectangle(self.x - 1, self.y + self.h, self.w + 2, 1, borderColor) -- bottom
gpu.filledRectangle(self.x - 1, self.y - 1, 1, self.h + 2, borderColor) -- left
gpu.filledRectangle(self.x + self.w, self.y - 1, 1, self.h + 2, borderColor) -- right
end
-- Draw items with proper vertical spacing
for i = 1, #self.items do
local item = self.items[i]
if item then
local itemY = contentY + ((i - 1) * dims.lineHeight)
-- Draw selection background for the full width of the content area
if i == self.selectedIndex then
gpu.filledRectangle(
self.x + borderOffset,
itemY,
self.w - (borderOffset * 2),
dims.lineHeight,
selectedBgColor
)
end
local fg = utils.mapColor(i == self.selectedIndex and
self.style.selectedTextColor or self.style.textColor)
local bg = i == self.selectedIndex and selectedBgColor or bgColor
gpu.drawText(contentX, itemY, tostring(item), fg, bg, fontSize)
end
end
else
-- Terminal version
for i = 1, self.h do
local item = self.items[i]
term.setCursorPos(self.x, self.y + i - 1)
if i == self.selectedIndex then
term.setBackgroundColor(self.style.selectedBackgroundColor)
term.setTextColor(self.style.selectedTextColor)
else
term.setBackgroundColor(self.style.backgroundColor)
term.setTextColor(self.style.textColor)
end
term.write(item and utils.padString(tostring(item), self.w) or string.rep(" ", self.w))
end
end
end
function List:handleEvent(event, ...)
if not self.visible then return false end
if event == "mouse_click" or event == "tm_monitor_touch" then
local button, x, y
if event == "mouse_click" then
button, x, y = ...
else
_, x, y = ...
button = 1 -- Treat touch as left click
end
if self:isPointInside(x, y) then
local index
if self.gui.gpu then
-- GPU mode calculation
local fontSize = self.style.fontSize or 1
local lineHeight = math.ceil(fontSize * 9)
local borderOffset = (self.style and self.style.border) and 1 or 0
local relativeY = y - (self.y + borderOffset)
index = math.floor(relativeY / lineHeight) + 1
else
-- Terminal mode calculation
index = y - self.y + 1
end
if index > 0 and index <= #self.items then
self:selectItem(index)
return true
end
end
end
return false
end
return List
local utils = require("gui-utils")
local Element = require("elements/Element")
local ProgressBar = setmetatable({}, {__index = Element})
ProgressBar.__index = ProgressBar
function ProgressBar.getDefaultStyle()
return {
borderColor = colors.lightGray,
fillColor = colors.blue,
backgroundColor = colors.black,
textColor = colors.white,
showPercentage = true,
showLabel = true,
labelPosition = "center", -- can be "left", "center", "right" for horizontal, "top", "center", "bottom" for vertical
labelFormat = "percentage" -- can be "percentage" or "value"
}
end
local styles = {
default = ProgressBar.getDefaultStyle()
}
function ProgressBar.new(config)
assert(type(config) == "table", "Config must be a table")
local self = Element.new(config.id, config.gui)
setmetatable(self, ProgressBar)
self.value = config.value or 0
self.maxValue = config.maxValue or 100
self.orientation = config.orientation or "horizontal"
self.style = utils.mergeTables(styles.default, config.style or {})
self.parent = config.parent
self.x = config.x or 1
self.y = config.y or 1
-- Set default dimensions based on orientation
if self.orientation == "horizontal" then
self.w = config.width or 20
self.h = config.height or 1
else
self.w = config.width or 1
self.h = config.height or 10
end
self.valueUnit = config.valueUnit or "%" -- New property for unit display
self.valuePrefix = config.valuePrefix or "" -- Optional prefix for the value
return self
end
function ProgressBar:setValue(value)
self.value = math.max(0, math.min(value, self.maxValue))
self:draw()
end
function ProgressBar:getValue()
return self.value
end
function ProgressBar:setMaxValue(maxValue)
self.maxValue = maxValue
self.value = math.min(self.value, maxValue)
self:draw()
end
function ProgressBar:formatLabel()
local progress = self.value / self.maxValue
if self.style.labelFormat == "percentage" then
return string.format("%d%%", math.floor(progress * 100))
else
return string.format("%s%d%s", self.valuePrefix, self.value, self.valueUnit)
end
end
function ProgressBar:getLabelPosition(labelText)
local x, y
local padding = 1 -- Add padding for better text placement
if self.orientation == "horizontal" then
-- For horizontal bars, center text vertically with floor division
-- The +1 helps center the text in bars with height > 1
y = self.y + math.floor((self.h - 1) / 2) + 1
if self.style.labelPosition == "left" then
x = self.x + padding
elseif self.style.labelPosition == "right" then
x = self.x + self.w + padding -- Place text after the bar
else -- center
x = self.x + math.floor((self.w - #labelText) / 2)
end
else
x = self.x + math.floor((self.w - #labelText) / 2)
if self.style.labelPosition == "top" then
y = self.y - 1 -- Move text above the bar
elseif self.style.labelPosition == "bottom" then
y = self.y + self.h + 1 -- Move text below the bar
else -- center
y = self.y + math.floor(self.h / 2)
end
end
return x, y
end
function ProgressBar:draw()
local gpu = self.gui.gpu
if not gpu then return end
-- Draw background
gpu.filledRectangle(
self.x, self.y,
self.w, self.h,
utils.mapColor(self.style.backgroundColor)
)
-- Calculate progress
local progress = self.value / self.maxValue
local fillSize
-- Draw progress fill
if self.orientation == "horizontal" then
fillSize = math.floor(progress * self.w)
if fillSize > 0 then
gpu.filledRectangle(
self.x, self.y,
fillSize, self.h,
utils.mapColor(self.style.fillColor)
)
end
else
fillSize = math.floor(progress * self.h)
if fillSize > 0 then
gpu.filledRectangle(
self.x, self.y + self.h - fillSize,
self.w, fillSize,
utils.mapColor(self.style.fillColor)
)
end
end
-- Draw border
gpu.rectangle(
self.x, self.y,
self.w, self.h,
utils.mapColor(self.style.borderColor)
)
-- Draw label if enabled
if self.style.showLabel then
local labelText = self:formatLabel()
local textX, textY = self:getLabelPosition(labelText)
-- Only show label if there's enough space
if self.orientation == "horizontal" and self.w >= #labelText then
gpu.drawText(textX, textY, labelText, utils.mapColor(self.style.textColor))
elseif self.orientation == "vertical" and self.h >= 3 then
gpu.drawText(textX, textY, labelText, utils.mapColor(self.style.textColor))
end
end
end
return ProgressBar
local utils = require("gui-utils")
local Element = require("elements/Element")
local Tabs = setmetatable({}, {__index = Element})
Tabs.__index = Tabs
local GUI = nil
Tabs.TAB_HEADER_HEIGHT = {
GPU = 10, -- Height in pixels for GPU mode
TERMINAL = 1 -- Height in characters for terminal mode
}
function Tabs.setGUI(guiRef)
GUI = guiRef
end
function Tabs.getDefaultStyle()
return {
tabColor = colors.gray,
activeTabColor = colors.lightGray,
textColor = colors.white,
activeTextColor = colors.black,
borderColor = colors.lightGray,
backgroundColor = colors.black,
border = true -- Add border property
}
end
local styles = {
default = Tabs.getDefaultStyle()
}
function Tabs.new(config)
assert(type(config) == "table", "Config must be a table")
assert(config.id, "Tabs id is required")
assert(config.gui, "Tabs gui is required")
local self = Element.new(config.id, config.gui)
setmetatable(self, Tabs)
self.style = utils.mergeTables(styles.default, config.style or {})
self.w = config.width or 30
self.h = config.height or 10
self.tabs = {}
self.activeTab = nil
self:adjustPosition(config)
return self
end
function Tabs:getContentArea()
local gpu = peripheral.find("tm_gpu")
local headerHeight = gpu and Tabs.TAB_HEADER_HEIGHT.GPU or Tabs.TAB_HEADER_HEIGHT.TERMINAL
local borderOffset = self.style.border and 2 or 0
-- Calculate number of rows needed for tabs
local maxRowWidth = self.w - 2
local rowCount = 1
local currentRowWidth = 0
for _, tab in ipairs(self.tabs) do
local tabWidth = self:getTextWidth(tab.label) + (gpu and 16 or 4)
if currentRowWidth + tabWidth > maxRowWidth and currentRowWidth > 0 then
rowCount = rowCount + 1
currentRowWidth = tabWidth + 2
else
currentRowWidth = currentRowWidth + tabWidth + 2
end
end
local totalHeaderHeight = rowCount * headerHeight
return {
x = self.x + borderOffset,
y = self.y + totalHeaderHeight + borderOffset,
width = self.w - (borderOffset * 2),
height = self.h - (totalHeaderHeight + (borderOffset * 2))
}
end
function Tabs:addTab(label)
local tab = {
label = label,
elements = {},
parentTab = self,
nextElementY = 1,
}
table.insert(self.tabs, tab)
if #self.tabs == 1 then
self.activeTab = tab
end
-- Update contentArea whenever a new element is added
function tab:adjustElementPosition(element)
self.contentArea = self.parentTab:getContentArea()
-- Simple absolute positioning relative to content area
element.x = self.contentArea.x + (element.x - 1)
element.y = self.contentArea.y + (element.y - 1)
element.parent = self
element.parentTab = self.parentTab
table.insert(self.elements, element)
end
return tab
end
function Tabs:updateElementVisibility()
for _, tab in ipairs(self.tabs) do
for _, element in ipairs(tab.elements) do
if GUI.elements[element.id] then
GUI.elements[element.id].visible = (tab == self.activeTab)
end
end
end
end
function Tabs:getTextWidth(text)
local gpu = peripheral.find("tm_gpu")
if gpu then
return gpu.getTextLength(text, 1)
end
return #text
end
function Tabs:draw()
if not self.visible then return end
local gpu = peripheral.find("tm_gpu")
-- Draw background first
local bgColor = utils.mapColor(self.style.backgroundColor)
if gpu then
gpu.filledRectangle(
self.x,
self.y,
self.w,
self.h,
bgColor
)
else
paintutils.drawFilledBox(self.x,
self.y + 2,
self.x + self.w - 1,
self.y + self.h - 1,
self.style.backgroundColor
)
end
-- Calculate tab rows
local tabRows = {{}}
local currentRow = tabRows[1]
local currentRowWidth = 0
local maxRowWidth = self.w - 2 -- Leave some margin
for i, tab in ipairs(self.tabs) do
local tabWidth = self:getTextWidth(tab.label) + (gpu and 16 or 4)
if currentRowWidth + tabWidth > maxRowWidth and #currentRow > 0 then
-- Start a new row
currentRow = {}
table.insert(tabRows, currentRow)
currentRowWidth = 0
end
table.insert(currentRow, {tab = tab, width = tabWidth})
currentRowWidth = currentRowWidth + tabWidth + 2 -- Include spacing
end
-- Draw tab headers in rows
local rowY = self.y
for _, row in ipairs(tabRows) do
local tabX = self.x
for _, tabInfo in ipairs(row) do
local tab = tabInfo.tab
local tabWidth = tabInfo.width
local isActive = (tab == self.activeTab)
if gpu then
local bgColor = utils.mapColor(isActive and self.style.activeTabColor or self.style.tabColor)
local textColor = utils.mapColor(isActive and self.style.activeTextColor or self.style.textColor)
gpu.filledRectangle(tabX, rowY, tabWidth, Tabs.TAB_HEADER_HEIGHT.GPU, bgColor)
local textX = tabX + (tabWidth - self:getTextWidth(tab.label)) / 2
gpu.drawText(textX, rowY + 2, tab.label, textColor, bgColor, 1)
else
term.setCursorPos(tabX, rowY)
term.setBackgroundColor(isActive and self.style.activeTabColor or self.style.tabColor)
term.setTextColor(isActive and self.style.activeTextColor or self.style.textColor)
term.write(string.rep(" ", tabWidth))
term.setCursorPos(tabX + 2, rowY)
term.write(tab.label)
end
tabX = tabX + tabWidth + 2
end
rowY = rowY + (gpu and Tabs.TAB_HEADER_HEIGHT.GPU or 1)
end
-- Update tab header height based on number of rows
local totalHeaderHeight = #tabRows * (gpu and Tabs.TAB_HEADER_HEIGHT.GPU or 1)
-- Draw content area border
if gpu then
local contentY = self.y + totalHeaderHeight
local borderColor = utils.mapColor(self.style.borderColor)
gpu.filledRectangle(
self.x,
contentY,
self.w,
self.h - totalHeaderHeight,
bgColor
)
gpu.rectangle(
self.x,
contentY,
self.w,
self.h - totalHeaderHeight,
borderColor
)
else
local contentY = self.y + #tabRows
paintutils.drawBox(self.x,
contentY,
self.x + self.w - 1,
self.y + self.h - 1,
self.style.borderColor
)
end
self:updateElementVisibility()
end
function Tabs:getBackgroundColor()
return self.style.backgroundColor
end
function Tabs:handleEvent(event, ...)
if event == "mouse_click" or event == "tm_monitor_touch" then
local button, x, y
if event == "mouse_click" then
button, x, y = ...
else
monitor, x, y = ...
end
local gpu = peripheral.find("tm_gpu")
local headerHeight = gpu and Tabs.TAB_HEADER_HEIGHT.GPU or Tabs.TAB_HEADER_HEIGHT.TERMINAL
-- Calculate and check all tab positions
local maxRowWidth = self.w - 2
local currentRow = 1
local currentX = self.x
local currentRowWidth = 0
for i, tab in ipairs(self.tabs) do
local tabWidth = self:getTextWidth(tab.label) + (gpu and 16 or 4)
-- Check if this tab needs to start a new row
if currentRowWidth + tabWidth > maxRowWidth and currentX > self.x then
currentRow = currentRow + 1
currentX = self.x
currentRowWidth = 0
end
local tabY = self.y + ((currentRow - 1) * headerHeight)
-- Check if click is within this tab's bounds
if x >= currentX and x < (currentX + tabWidth) and
y >= tabY and y < (tabY + headerHeight) then
if self.activeTab ~= tab then
local oldTab = self.activeTab
self.activeTab = tab
self:updateElementVisibility()
if self.gui then
self.gui.events:emit("tabChange", self, tab, oldTab)
end
return true
end
end
currentX = currentX + tabWidth + 2
currentRowWidth = currentRowWidth + tabWidth + 2
end
end
return false
end
return {
new = Tabs.new,
styles = styles,
getDefaultStyle = Tabs.getDefaultStyle,
setGUI = Tabs.setGUI
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment