add generated mandelbrot demo

master
Ondřej Hruška 1 day ago
parent dd2b153bbe
commit 298b09ddc9
  1. 124
      lua/demo/mandelbrot.lua

@ -0,0 +1,124 @@
-- The Mandelbrot set rendered as colored ASCII art, drawn row by row with
-- the tui positioning tools (alternate screen, moveTo, clearScreen), then a
-- short animated zoom into "seahorse valley". When output is piped, it
-- degrades to a single plain-text render.
--
-- Run with: cargo run -- lua/demo/mandelbrot.lua
-- One character + color per escape-speed bucket, slowest to fastest.
local CHARS = { ".", ":", "-", "=", "+", "*", "#", "%", "@" }
local COLORS = {
"#1e3a8a", "#1d4ed8", "#0284c7", "#0d9488", "#16a34a",
"#84cc16", "#eab308", "#f97316", "#ef4444",
}
--- How many iterations until the point escapes, or nil if it stays bounded
--- (i.e. belongs to the set).
local function escapeTime(cx, cy, maxIter)
local x, y = 0, 0
for i = 1, maxIter do
local x2, y2 = x * x, y * y
if x2 + y2 > 4 then
return i
end
y = 2 * x * y + cy
x = x2 - y2 + cx
end
return nil
end
--- Render one row as a styled string, batching same-colored runs into a
--- single tag so the escape-code overhead stays small.
local function renderRow(cy, xMin, dx, cols, maxIter)
local parts, run, runColor = {}, {}, nil
local function flush()
if #run == 0 then
return
end
local text = table.concat(run)
parts[#parts + 1] = runColor and ("<%s>%s</>"):format(runColor, text) or text
run = {}
end
for col = 0, cols - 1 do
local i = escapeTime(xMin + col * dx, cy, maxIter)
local ch, color = " ", nil -- inside the set: leave the cell empty
if i then
local idx = math.clamp(math.floor(math.sqrt(i / maxIter) * #CHARS) + 1, 1, #CHARS)
ch, color = CHARS[idx], COLORS[idx]
end
if color ~= runColor then
flush()
runColor = color
end
run[#run + 1] = ch
end
flush()
return tui.styled(table.concat(parts))
end
--- Draw one full frame. Interactively each row is placed with tui.moveTo
--- (row 1 is a header line); piped, rows are just printed in order.
local function drawFrame(cx, cy, scale, maxIter, cols, rows, interactive)
-- Terminal cells are roughly twice as tall as they are wide, so step
-- twice as far in y to keep the set round.
local dx = scale / cols
local dy = dx * 2
local xMin = cx - dx * cols / 2
local yMin = cy - dy * rows / 2
if interactive then
tui.moveTo(1, 1)
tui.raw(tui.styled(
("<clear=line><b>Mandelbrot</b> center %.9f%+.9fi width %.2e max iter %d")
:format(cx, cy, scale, maxIter)
))
end
for row = 1, rows do
local line = renderRow(yMin + (row - 1) * dy, xMin, dx, cols, maxIter)
if interactive then
tui.moveTo(row + 1, 1)
tui.raw(line)
else
print(line)
end
end
end
tui.setTitle("mandelbrot")
local info = tui.screenInfo()
local cols = info.width or 100
local rows = (info.height or 32) - 2 -- header + one spare line for the cursor
-- The classic full view: x roughly [-2.1, 1.1].
local fullCx, fullCy, fullScale = -0.5, 0, 3.2
if not info.tty then
drawFrame(fullCx, fullCy, fullScale, 80, cols, rows, false)
return
end
tui.altScreen(true)
tui.clearScreen()
tui.showCursor(false)
drawFrame(fullCx, fullCy, fullScale, 80, cols, rows, true)
os.sleep(2)
-- Zoom into "seahorse valley" between the main cardioid and the period-2
-- bulb, cranking up the iteration budget as detail gets finer.
local cx, cy = -0.743643887, 0.131825904
local scale = fullScale
for frame = 1, 40 do
scale = scale * 0.85
drawFrame(cx, cy, scale, 60 + frame * 8, cols, rows, true)
os.sleep(0.03)
end
os.sleep(2)
tui.altScreen(false)
tui.showCursor(true)
tui.success(("zoomed to a window %.2e units wide — that's about %dx magnification"):format(
scale,
math.round(fullScale / scale)
))
Loading…
Cancel
Save