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