Web Calculator Example
This example demonstrates state management patterns, modular components, and action-based routing for a calculator application.
Project Structure
calculator/
├── src/
│ ├── app.lua # Main application entry
│ ├── state.lua # State management module
│ ├── calculator.lua # Calculator component
│ └── nixi.lua # Framework module
├── public/
│ └── style.css
└── server.lua
State Management (state.lua)
Centralized state management with history tracking:
-- state.lua
local M = {}
M.state = {
display = "0",
firstOperand = nil,
operator = nil,
waitingForSecondOperand = false,
history = {}
}
function M.setDisplay(value)
M.state.display = tostring(value)
end
function M.getDisplay()
return M.state.display
end
function M.setOperator(op)
M.state.operator = op
M.state.firstOperand = tonumber(M.state.display)
M.state.waitingForSecondOperand = true
end
function M.calculate(secondOperand)
local result
local first = M.state.firstOperand or 0
local op = M.state.operator
if op == "+" then
result = first + secondOperand
elseif op == "-" then
result = first - secondOperand
elseif op == "*" then
result = first * secondOperand
elseif op == "/" then
if secondOperand == 0 then
return "Error"
end
result = first / secondOperand
else
result = secondOperand
end
table.insert(M.state.history, {
expression = string.format("%s %s %s = %s", first, op or "", secondOperand, result)
})
M.state.display = tostring(result)
M.state.operator = nil
M.state.firstOperand = nil
M.state.waitingForSecondOperand = false
return result
end
function M.clear()
M.state.display = "0"
M.state.firstOperand = nil
M.state.operator = nil
M.state.waitingForSecondOperand = false
end
function M.clearHistory()
M.state.history = {}
end
return M
Calculator Component (calculator.lua)
Reusable calculator display and buttons:
-- calculator.lua
local Nixi = require("nixi.init")
local state = require("state")
local M = {}
function M.display()
return Nixi.div(
{
id = "calc-display",
style = "background: #0f0f1a; padding: 20px; border-radius: 12px; text-align: right; margin-bottom: 16px;"
},
Nixi.span(
{
id = "display-value",
style = "font-size: 32px; font-weight: bold; color: #00ff88; font-family: monospace;"
},
state.getDisplay()
)
)
end
function M.button(value, class, action)
return Nixi.button(
{
["hx-post"] = action,
["hx-target"] = "#calc-display",
["hx-vals"] = string.format('{{"value":"%s"}}', value),
style = "width: 60px; height: 60px; font-size: 24px; border: none; border-radius: 12px; cursor: pointer; transition: all 0.1s;"
},
value
)
end
function M.row(...)
local buttons = {...}
return Nixi.div(
{ style = "display: flex; gap: 10px; margin-bottom: 10px; justify-content: center;" },
table.unpack(buttons)
)
end
function M.keypad()
return Nixi.div(
{ style = "background: #1a1a2e; padding: 20px; border-radius: 16px;" },
M.display(),
M.row(
M.button("C", "clear", "/calc/clear"),
M.button("±", "negate", "/calc/negate"),
M.button("%", "percent", "/calc/percent"),
M.button("÷", "divide", "/calc/operator")
),
M.row(
M.button("7", "seven", "/calc/digit"),
M.button("8", "eight", "/calc/digit"),
M.button("9", "nine", "/calc/digit"),
M.button("×", "multiply", "/calc/operator")
),
M.row(
M.button("4", "four", "/calc/digit"),
M.button("5", "five", "/calc/digit"),
M.button("6", "six", "/calc/digit"),
M.button("-", "subtract", "/calc/operator")
),
M.row(
M.button("1", "one", "/calc/digit"),
M.button("2", "two", "/calc/digit"),
M.button("3", "three", "/calc/digit"),
M.button("+", "add", "/calc/operator")
),
M.row(
M.button("0", "zero", "/calc/digit"),
M.button(".", "decimal", "/calc/decimal"),
M.button("=", "equals", "/calc/equals")
)
)
end
return M
Main Application (app.lua)
#!/usr/bin/env lua
---@meta
package.path = "src/?.lua;src/?/init.lua;" .. package.path
local Nixi = require("nixi.init")
local state = require("state")
local calc = require("calculator")
local app = Nixi.new({
host = "127.0.0.1",
port = 3000,
})
app:get("/", function(ctx)
return Nixi.Layout({
title = "Nixi Calculator",
body = Nixi.div(
{ style = "max-width: 320px; margin: 50px auto;" },
Nixi.h1(nil, "Calculator"),
calc.keypad(),
Nixi.div(
{ id = "history", style = "margin-top: 20px;" },
#state.state.history > 0 and Nixi.h3(nil, "History") or nil,
Nixi.ul(
{ style = "list-style: none; padding: 0;" },
table.concat(
vim.tbl_map(function(h)
return Nixi.li({ style = "padding: 8px; background: #1a1a2e; margin: 4px 0; border-radius: 8px;" }, h.expression)
end, state.state.history)
)
)
)
)
})
end)
app:post("/calc/digit", function(ctx)
local value = ctx.request.body.value
if state.state.waitingForSecondOperand then
state.setDisplay(value)
state.state.waitingForSecondOperand = false
else
local current = state.getDisplay()
if current == "0" then
state.setDisplay(value)
else
state.setDisplay(current .. value)
end
end
return calc.display()
end)
app:post("/calc/operator", function(ctx)
local value = ctx.request.body.value
if state.state.operator and state.state.firstOperand then
local second = tonumber(state.getDisplay())
state.calculate(second)
end
state.setOperator(value)
return calc.display()
end)
app:post("/calc/equals", function(ctx)
if state.state.operator and state.state.firstOperand then
local second = tonumber(state.getDisplay())
state.calculate(second)
end
return calc.display()
end)
app:post("/calc/clear", function(ctx)
state.clear()
return calc.display()
end)
app:post("/calc/negate", function(ctx)
local current = tonumber(state.getDisplay())
state.setDisplay(-current)
return calc.display()
end)
app:post("/calc/percent", function(ctx)
local current = tonumber(state.getDisplay())
state.setDisplay(current / 100)
return calc.display()
end)
app:post("/calc/decimal", function(ctx)
local current = state.getDisplay()
if not current:match("%.") then
state.setDisplay(current .. ".")
end
return calc.display()
end)
return app
Action Routing Pattern
Each button posts to a specific action endpoint:
-- Digit handling
app:post("/calc/digit", function(ctx)
local value = ctx.request.body.value
-- Process digit input
end)
-- Operator handling
app:post("/calc/operator", function(ctx)
local value = ctx.request.body.value
-- Set operator
end)
-- Clear handling
app:post("/calc/clear", function(ctx)
state.clear()
end)
Key Patterns
| Pattern | Description |
|---|---|
| State Module | Centralized state.lua for all app state |
| Component Functions | Reusable Nixi component builders |
| Action Routes | POST endpoints for each user action |
| hx-vals | Pass button values via HTMX values |
| Partial Updates | Return only updated elements |