Skip to content

Instantly share code, notes, and snippets.

@planetis-m
Created April 13, 2026 18:50
Show Gist options
  • Select an option

  • Save planetis-m/908c859199cd89e6087082a87628d4a0 to your computer and use it in GitHub Desktop.

Select an option

Save planetis-m/908c859199cd89e6087082a87628d4a0 to your computer and use it in GitHub Desktop.
import std/[os, unicode, times]
import chroma
import figdraw/windyshim
import figdraw/commons
import figdraw/fignodes
import figdraw/figrender as glrenderer
# Text handling: Using NfClipContent flag instead of manual truncation.
# This lets figdraw handle clipping automatically. The trade-off is that
# clipped text won't have "..." ellipsis, but the code is cleaner
# and more efficient (no separate typeset just for measurement).
const
FontName {.strdefine: "figdraw.defaultfont".}: string = "Ubuntu.ttf"
RunOnce {.booldefine: "figdraw.runOnce".}: bool = false
BaseFontSize = 18.0'f32
SmallFontSize = 14.0'f32
HeaderFontSize = 28.0'f32
bgColor = rgba(244, 243, 241, 255)
panelColor = rgba(255, 255, 255, 255)
panelAltColor = rgba(247, 248, 249, 255)
borderColor = rgba(216, 219, 223, 255)
textColor = rgba(29, 31, 34, 255)
mutedTextColor = rgba(103, 107, 112, 255)
accentColor = rgba(44, 120, 220, 255)
selectedRowColor = rgba(233, 240, 250, 255)
successColor = rgba(46, 160, 67, 255)
dangerColor = rgba(208, 56, 76, 255)
placeholderColor = rgba(126, 130, 135, 255)
dividerColor = rgba(229, 232, 235, 255)
type
TodoItem = object
text: string
done: bool
Focus = enum
ComposerFocus, ListFocus
HoverKind = enum
HoverNone, HoverComposer, HoverRow, HoverCheckbox, HoverDelete
HoverTarget = object
kind: HoverKind
index: int
AppState = object
items: seq[TodoItem]
inputText: string
focus: Focus
selectedIndex: int
scrollIndex: int
hover: HoverTarget
func insetRect(r: Rect; pad: float32): Rect =
rect(r.x + pad, r.y + pad, max(0'f32, r.w - pad * 2), max(0'f32, r.h - pad * 2))
func contains(r: Rect; x, y: float32): bool =
x >= r.x and y >= r.y and x < r.x + r.w and y < r.y + r.h
func countDone(items: seq[TodoItem]): int =
for item in items:
if item.done:
inc result
func summaryText(total, done: int): string =
let remaining = total - done
if total == 0:
return "No tasks"
if done == 0:
return $remaining & " remaining"
if remaining == 0:
return "All completed"
$remaining & " remaining, " & $done & " completed"
proc removeLastRune(s: var string) =
if s.len == 0:
return
let (_, runeBytes) = lastRune(s, s.high)
s.setLen(s.len - runeBytes)
func rowHeightFor(font: FigFont): float32 =
font.size * 1.6'f32
func visibleRowsFor(listInner: Rect; rowHeight: float32): int =
max(1, int(listInner.h / rowHeight))
func checkboxRect(rowRect: Rect): Rect =
let size = min(18'f32, max(12'f32, rowRect.h - 12))
rect(rowRect.x + 10, rowRect.y + (rowRect.h - size) / 2, size, size)
func deleteRect(rowRect: Rect): Rect =
let size = min(18'f32, max(12'f32, rowRect.h - 12))
rect(rowRect.x + rowRect.w - 10 - size, rowRect.y + (rowRect.h - size) / 2, size, size)
func rowRectFor(listInner: Rect; rowHeight: float32; visibleIndex: int): Rect =
rect(listInner.x, listInner.y + rowHeight * visibleIndex.float32, listInner.w, rowHeight)
proc clampSelection(state: var AppState; visibleRows: int) =
if state.items.len == 0:
state.selectedIndex = -1
state.scrollIndex = 0
return
state.selectedIndex = clamp(state.selectedIndex, 0, state.items.len - 1)
let maxScroll = max(0, state.items.len - visibleRows)
state.scrollIndex = clamp(state.scrollIndex, 0, maxScroll)
if state.selectedIndex < state.scrollIndex:
state.scrollIndex = state.selectedIndex
elif state.selectedIndex >= state.scrollIndex + visibleRows:
state.scrollIndex = state.selectedIndex - visibleRows + 1
state.scrollIndex = clamp(state.scrollIndex, 0, maxScroll)
proc hitList(
listInner: Rect; rowHeight: float32; scrollIndex: int; items: seq[TodoItem];
x, y: float32
): HoverTarget =
if not listInner.contains(x, y):
return HoverTarget(kind: HoverNone, index: -1)
let visibleIndex = int((y - listInner.y) / rowHeight)
let itemIndex = scrollIndex + visibleIndex
if itemIndex < 0 or itemIndex >= items.len:
return HoverTarget(kind: HoverNone, index: -1)
let rowRect = rowRectFor(listInner, rowHeight, visibleIndex)
if deleteRect(rowRect).contains(x, y):
return HoverTarget(kind: HoverDelete, index: itemIndex)
if checkboxRect(rowRect).contains(x, y):
return HoverTarget(kind: HoverCheckbox, index: itemIndex)
HoverTarget(kind: HoverRow, index: itemIndex)
func addRectangle*(
renders: var Renders; z: ZLevel; parent: FigIdx; box: Rect; fillColor: ColorRGBX;
strokeColor: ColorRGBX = rgba(0, 0, 0, 0); strokeWeight = 0.0'f32;
corners: array[4, float32] = [0'f32, 0, 0, 0];
flags: set[FigFlags] = {}
): FigIdx =
var node = Fig(
kind: nkRectangle,
childCount: 0,
flags: flags,
screenBox: box,
fill: fill(fillColor),
corners: corners,
)
if strokeWeight > 0:
node.stroke = RenderStroke(weight: strokeWeight, fill: strokeColor.color)
renders.addChild(z, parent, node)
proc addText*(
renders: var Renders; z: ZLevel; parent: FigIdx; font: FigFont; box: Rect;
text: string; color: ColorRGBX; hAlign = Left; vAlign = Middle;
clip: bool = false
): FigIdx =
let layout = typeset(
rect(0, 0, box.w, box.h),
[span(font, fill(color), text)],
hAlign = hAlign,
vAlign = vAlign,
minContent = false,
wrap = false,
)
renders.addChild(
z,
parent,
Fig(
kind: nkText,
childCount: 0,
flags: if clip: {NfClipContent} else: {},
screenBox: box,
textLayout: layout,
),
)
func createRoot(renders: var Renders; z: ZLevel; w, h: float32; bgColor: ColorRGBX): FigIdx =
## Creates the root background node
renders.addRoot(
z,
Fig(
kind: nkRectangle,
childCount: 0,
screenBox: rect(0, 0, w, h),
fill: fill(bgColor),
),
)
func createPanel*(
renders: var Renders; z: ZLevel; parent: FigIdx;
box: Rect; fillColor: ColorRGBX; borderColor: ColorRGBX;
borderWeight: float32 = 1.0'f32;
cornerRadius: float32 = 0.0'f32;
): FigIdx =
renders.addRectangle(z, parent, box, fillColor, borderColor, borderWeight, [cornerRadius, cornerRadius, cornerRadius, cornerRadius])
func createButton*(
renders: var Renders; z: ZLevel; parent: FigIdx;
box: Rect; fillColor: ColorRGBX; borderColor: ColorRGBX;
isFocused: bool = false; cornerRadius: float32 = 12.0'f32;
): FigIdx =
let finalBorderColor = if isFocused: borderColor else: borderColor
let finalBorderWeight = if isFocused: 1.5'f32 else: 1.0'f32
renders.createPanel(z, parent, box, fillColor, finalBorderColor, finalBorderWeight, cornerRadius)
proc addCheckbox*(
renders: var Renders; z: ZLevel; parent: FigIdx;
box: Rect; isChecked: bool; font: FigFont; cornerRadius: float32 = 4.0'f32
): tuple[boxIdx, textIdx: FigIdx] =
let fillColor = if isChecked: successColor else: panelColor
let strokeColor = if isChecked: successColor else: borderColor
let boxIdx = renders.createPanel(z, parent, box, fillColor, strokeColor, 1.0'f32, cornerRadius)
var textIdx: FigIdx
if isChecked:
textIdx = renders.addText(z, parent, font, box, "", panelColor, Center, Middle)
(boxIdx: boxIdx, textIdx: textIdx)
proc addIconButton*(
renders: var Renders; z: ZLevel; parent: FigIdx;
box: Rect; symbol: string; font: FigFont; fillColor: ColorRGBX; strokeColor: ColorRGBX;
isHovered: bool = false; cornerRadius: float32 = 4.0'f32
): tuple[boxIdx, textIdx: FigIdx] =
let finalFillColor = if isHovered: strokeColor else: fillColor
let finalTextColor = if isHovered: fillColor else: strokeColor
let boxIdx = renders.createPanel(z, parent, box, finalFillColor, strokeColor, 1.0'f32, cornerRadius)
let textIdx = renders.addText(z, parent, font, box, symbol, finalTextColor, Center, Middle)
(boxIdx: boxIdx, textIdx: textIdx)
func addDivider*(
renders: var Renders; z: ZLevel; parent: FigIdx;
box: Rect; color: ColorRGBX
): FigIdx =
renders.addRectangle(z, parent, box, color)
proc makeRenderTree(
w, h: float32; headerFont, bodyFont, smallFont: FigFont; state: AppState
): Renders =
result = Renders()
let z = 0.ZLevel
let rootIdx = result.createRoot(z, w, h, bgColor)
let outerPad = 20'f32
let gap = 12'f32
let headerRect = rect(outerPad, outerPad, w - outerPad * 2, 72)
let composerRect = rect(outerPad, headerRect.y + headerRect.h + gap, w - outerPad * 2, 64)
let statusRect = rect(outerPad, h - outerPad - 34, w - outerPad * 2, 34)
let listRect = rect(
outerPad,
composerRect.y + composerRect.h + gap,
w - outerPad * 2,
statusRect.y - (composerRect.y + composerRect.h + gap) - gap,
)
discard result.createPanel(z, rootIdx, headerRect, panelColor, borderColor, 1.0'f32, 12.0'f32)
discard result.createButton(z, rootIdx, composerRect, panelColor, accentColor, state.focus == ComposerFocus, 12.0'f32)
discard result.createPanel(z, rootIdx, listRect, panelColor, borderColor, 1.0'f32, 12.0'f32)
discard result.createPanel(z, rootIdx, statusRect, panelColor, borderColor, 1.0'f32, 10.0'f32)
discard result.addText(
z, rootIdx, headerFont, insetRect(headerRect, 20),
"Tasks", textColor, Left, Middle
)
let composerInner = insetRect(composerRect, 14)
if state.inputText.len == 0:
discard result.addText(
z, rootIdx, bodyFont, composerInner, "Add a task...", placeholderColor, Left, Middle
)
else:
discard result.addText(z, rootIdx, bodyFont, composerInner, state.inputText, textColor, Left, Middle)
let listInner = insetRect(listRect, 8)
let rowHeight = rowHeightFor(bodyFont)
let visibleRows = visibleRowsFor(listInner, rowHeight)
let startIdx = state.scrollIndex
let endIdx = min(state.items.len, startIdx + visibleRows)
for itemIndex in startIdx ..< endIdx:
let visibleIndex = itemIndex - startIdx
let rowRect = rowRectFor(listInner, rowHeight, visibleIndex)
let rowBg =
if itemIndex == state.selectedIndex: selectedRowColor
elif (visibleIndex mod 2) == 0: panelColor
else: panelAltColor
discard result.addRectangle(z, rootIdx, rowRect, rowBg)
let dividerY = rowRect.y + rowRect.h - 1
discard result.addDivider(z, rootIdx, rect(rowRect.x, dividerY, rowRect.w, 1), dividerColor)
let checkbox = checkboxRect(rowRect)
discard result.addCheckbox(z, rootIdx, checkbox, state.items[itemIndex].done, smallFont, 4.0'f32)
let deleteBox = deleteRect(rowRect)
let deleteHovered = state.hover.kind == HoverDelete and state.hover.index == itemIndex
discard result.addIconButton(z, rootIdx, deleteBox, "×", smallFont, panelColor, dangerColor, deleteHovered, 4.0'f32)
let textLeft = checkbox.x + checkbox.w + 12
let textRight = deleteBox.x - 12
let textRect = rect(textLeft, rowRect.y, max(0'f32, textRight - textLeft), rowRect.h)
discard result.addText(
z, rootIdx, bodyFont, textRect, state.items[itemIndex].text,
if state.items[itemIndex].done: mutedTextColor else: textColor,
Left, Middle,
clip = true,
)
discard result.addText(
z, rootIdx, smallFont, insetRect(statusRect, 12),
summaryText(state.items.len, countDone(state.items)),
mutedTextColor, Left, Middle,
)
proc refreshHover(state: var AppState; bodyFont: FigFont; width, height: float32; mouse: IVec2) =
let outerPad = 20'f32
let gap = 12'f32
let headerRect = rect(outerPad, outerPad, width - outerPad * 2, 72)
let composerRect = rect(outerPad, headerRect.y + headerRect.h + gap, width - outerPad * 2, 64)
let statusRect = rect(outerPad, height - outerPad - 34, width - outerPad * 2, 34)
let listRect = rect(
outerPad,
composerRect.y + composerRect.h + gap,
width - outerPad * 2,
statusRect.y - (composerRect.y + composerRect.h + gap) - gap,
)
let listInner = insetRect(listRect, 8)
let rowHeight = rowHeightFor(bodyFont)
if composerRect.contains(mouse.x.float32, mouse.y.float32):
state.hover = HoverTarget(kind: HoverComposer, index: -1)
else:
state.hover = hitList(
listInner, rowHeight, state.scrollIndex, state.items, mouse.x.float32, mouse.y.float32
)
when isMainModule:
setFigDataDir("/tmp/figdraw/data")
registerStaticTypeface("Ubuntu.ttf", "/tmp/figdraw/data/Ubuntu.ttf")
let typefaceId = loadTypeface(FontName, @["Ubuntu.ttf"])
let headerFont = FigFont(typefaceId: typefaceId, size: HeaderFontSize)
let bodyFont = FigFont(typefaceId: typefaceId, size: BaseFontSize)
let smallFont = FigFont(typefaceId: typefaceId, size: SmallFontSize)
var appRunning = true
let size = ivec2(760, 560)
let window = newWindyWindow(
size = size,
fullscreen = false,
title = windyWindowTitle("Todo"),
)
setFigUiScale window.contentScale()
let renderer =
glrenderer.newFigRenderer(atlasSize = 2048, backendState = WindyRenderBackend())
renderer.setupBackend(window)
var state = AppState(
items: @[
TodoItem(text: "Review this week's priorities", done: true),
TodoItem(text: "Reply to client email", done: false),
TodoItem(text: "Prepare tomorrow's notes", done: false),
],
inputText: "",
focus: ComposerFocus,
selectedIndex: 0,
scrollIndex: 0,
hover: HoverTarget(kind: HoverNone, index: -1),
)
var needsRedraw = true
var frames = 0
var fpsFrames = 0
var fpsStart = epochTime()
window.onCloseRequest = proc() =
appRunning = false
window.onResize = proc() =
setFigUiScale window.contentScale()
needsRedraw = true
window.onRune = proc(rune: Rune) =
if state.focus == ComposerFocus:
state.inputText.add $rune
needsRedraw = true
proc addItemFromInput() =
let text = state.inputText.strip()
if text.len == 0:
return
state.items.add TodoItem(text: text, done: false)
state.inputText.setLen(0)
if state.selectedIndex < 0:
state.selectedIndex = 0
proc deleteSelected() =
if state.selectedIndex < 0 or state.selectedIndex >= state.items.len:
return
state.items.delete(state.selectedIndex)
if state.items.len == 0:
state.selectedIndex = -1
state.scrollIndex = 0
else:
state.selectedIndex = min(state.selectedIndex, state.items.len - 1)
proc redraw() =
let logical = window.logicalSize()
refreshHover(state, bodyFont, logical.x, logical.y, window.mousePos())
let rowHeight = rowHeightFor(bodyFont)
let listInner = rect(28, 184, logical.x - 56, logical.y - 246)
let visibleRows = visibleRowsFor(listInner, rowHeight)
clampSelection(state, visibleRows)
renderer.beginFrame()
var renders = makeRenderTree(logical.x, logical.y, headerFont, bodyFont, smallFont, state)
renderer.renderFrame(renders, logical)
renderer.endFrame()
try:
while appRunning:
pollEvents()
window.runeInputEnabled = state.focus == ComposerFocus
let logical = window.logicalSize()
let rowHeight = rowHeightFor(bodyFont)
let listInner = rect(28, 184, logical.x - 56, logical.y - 246)
let visibleRows = visibleRowsFor(listInner, rowHeight)
clampSelection(state, visibleRows)
refreshHover(state, bodyFont, logical.x, logical.y, window.mousePos())
if window.buttonPressed[KeyEscape]:
appRunning = false
if window.buttonPressed[KeyBackspace] and state.focus == ComposerFocus:
removeLastRune(state.inputText)
needsRedraw = true
if window.buttonPressed[KeyEnter]:
if state.focus == ComposerFocus:
addItemFromInput()
elif state.selectedIndex >= 0:
state.items[state.selectedIndex].done = not state.items[state.selectedIndex].done
needsRedraw = true
if window.buttonPressed[KeyTab]:
state.focus =
if state.focus == ComposerFocus: ListFocus
else: ComposerFocus
needsRedraw = true
if window.buttonPressed[KeyUp] and state.items.len > 0:
state.focus = ListFocus
if state.selectedIndex < 0:
state.selectedIndex = 0
else:
state.selectedIndex = max(0, state.selectedIndex - 1)
needsRedraw = true
if window.buttonPressed[KeyDown] and state.items.len > 0:
state.focus = ListFocus
if state.selectedIndex < 0:
state.selectedIndex = 0
else:
state.selectedIndex = min(state.items.len - 1, state.selectedIndex + 1)
needsRedraw = true
if window.buttonPressed[KeyDelete]:
deleteSelected()
needsRedraw = true
let scrollY = window.scrollDelta().y
if scrollY != 0 and listInner.contains(window.mousePos().x.float32, window.mousePos().y.float32):
let maxScroll = max(0, state.items.len - visibleRows)
if scrollY > 0:
state.scrollIndex = min(maxScroll, state.scrollIndex + 1)
else:
state.scrollIndex = max(0, state.scrollIndex - 1)
needsRedraw = true
if window.buttonPressed[MouseLeft]:
let mouse = window.mousePos()
let composerRect = rect(20, 104, logical.x - 40, 64)
if composerRect.contains(mouse.x.float32, mouse.y.float32):
state.focus = ComposerFocus
else:
state.focus = ListFocus
case state.hover.kind
of HoverCheckbox:
if state.hover.index >= 0 and state.hover.index < state.items.len:
state.items[state.hover.index].done = not state.items[state.hover.index].done
state.selectedIndex = state.hover.index
of HoverDelete:
if state.hover.index >= 0 and state.hover.index < state.items.len:
state.items.delete(state.hover.index)
if state.items.len == 0:
state.selectedIndex = -1
state.scrollIndex = 0
else:
state.selectedIndex = min(state.hover.index, state.items.len - 1)
of HoverRow:
state.selectedIndex = state.hover.index
else:
discard
needsRedraw = true
if needsRedraw:
redraw()
needsRedraw = false
inc frames
inc fpsFrames
let now = epochTime()
let elapsed = now - fpsStart
if elapsed >= 1.0:
discard fpsFrames.float / elapsed
fpsFrames = 0
fpsStart = now
if RunOnce and frames >= 1:
appRunning = false
when not defined(emscripten):
sleep(16)
finally:
when not defined(emscripten):
window.close()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment