-- 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( ("Mandelbrot 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) ))