Skip to content

Instantly share code, notes, and snippets.

@planetis-m
Last active April 13, 2026 18:41
Show Gist options
  • Select an option

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

Select an option

Save planetis-m/b36387670ee6e76237c5e5d15f8cba69 to your computer and use it in GitHub Desktop.
import std/[os, unicode, strutils, times]
import chroma
import figdraw/windyshim
import figdraw/commons
import figdraw/fignodes
import figdraw/figrender as glrenderer
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
proc 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))
proc 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
proc countDone(items: seq[TodoItem]): int =
for item in items:
if item.done:
inc result
proc 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)
proc textWidth(font: FigFont; text: string): float32 =
if text.len == 0:
return 0
let layout = typeset(
rect(0, 0, 4096, 256),
[span(font, fill(textColor), text)],
hAlign = Left,
vAlign = Top,
minContent = false,
wrap = false,
)
max(layout.bounding.w, layout.maxSize.x)
proc truncateText(font: FigFont; text: string; maxWidth: float32): string =
if maxWidth <= 0:
return ""
if textWidth(font, text) <= maxWidth:
return text
let ellipsis = "..."
if textWidth(font, ellipsis) > maxWidth:
return ""
var resultText = ""
for rune in text.toRunes:
let next = resultText & $rune
if textWidth(font, next & ellipsis) > maxWidth:
break
resultText = next
if resultText.len == 0:
return ellipsis
resultText.add ellipsis
resultText
proc rowHeightFor(font: FigFont): float32 =
font.size * 1.6'f32
proc visibleRowsFor(listInner: Rect; rowHeight: float32): int =
max(1, int(listInner.h / rowHeight))
proc 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)
proc 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)
proc 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)
proc addRectNode(
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] = {}
) =
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)
discard renders.addChild(z, parent, node)
proc addTextNode(
renders: var Renders; z: ZLevel; parent: FigIdx; font: FigFont; box: Rect;
text: string; color: ColorRGBX; hAlign = Left; vAlign = Middle
) =
let layout = typeset(
rect(0, 0, box.w, box.h),
[span(font, fill(color), text)],
hAlign = hAlign,
vAlign = vAlign,
minContent = false,
wrap = false,
)
discard renders.addChild(
z,
parent,
Fig(
kind: nkText,
childCount: 0,
flags: {},
screenBox: box,
textLayout: layout,
),
)
proc makeRenderTree(
w, h: float32; headerFont, bodyFont, smallFont: FigFont; state: AppState
): Renders =
result = Renders()
let z = 0.ZLevel
let rootIdx = result.addRoot(
z,
Fig(
kind: nkRectangle,
childCount: 0,
screenBox: rect(0, 0, w, h),
fill: fill(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,
)
addRectNode(result, z, rootIdx, headerRect, panelColor, borderColor, 1.0, [12'f32, 12, 12, 12])
addRectNode(
result,
z,
rootIdx,
composerRect,
panelColor,
if state.focus == ComposerFocus: accentColor else: borderColor,
1.5,
[12'f32, 12, 12, 12],
)
addRectNode(result, z, rootIdx, listRect, panelColor, borderColor, 1.0, [12'f32, 12, 12, 12])
addRectNode(result, z, rootIdx, statusRect, panelColor, borderColor, 1.0, [10'f32, 10, 10, 10])
addTextNode(
result,
z,
rootIdx,
headerFont,
insetRect(headerRect, 20),
"Tasks",
textColor,
Left,
Middle,
)
let composerInner = insetRect(composerRect, 14)
if state.inputText.len == 0:
addTextNode(
result, z, rootIdx, bodyFont, composerInner, "Add a task...", placeholderColor, Left, Middle
)
else:
addTextNode(result, 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
addRectNode(result, z, rootIdx, rowRect, rowBg)
let dividerY = rowRect.y + rowRect.h - 1
addRectNode(
result,
z,
rootIdx,
rect(rowRect.x, dividerY, rowRect.w, 1),
dividerColor,
)
let checkbox = checkboxRect(rowRect)
addRectNode(
result,
z,
rootIdx,
checkbox,
if state.items[itemIndex].done: successColor else: panelColor,
if state.items[itemIndex].done: successColor else: borderColor,
1.0,
[4'f32, 4, 4, 4],
)
if state.items[itemIndex].done:
addTextNode(result, z, rootIdx, smallFont, checkbox, "", panelColor, Center, Middle)
let deleteBox = deleteRect(rowRect)
let deleteHovered =
state.hover.kind == HoverDelete and state.hover.index == itemIndex
addRectNode(
result,
z,
rootIdx,
deleteBox,
if deleteHovered: dangerColor else: panelColor,
dangerColor,
1.0,
[4'f32, 4, 4, 4],
)
addTextNode(
result,
z,
rootIdx,
smallFont,
deleteBox,
"×",
if deleteHovered: panelColor else: dangerColor,
Center,
Middle,
)
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)
let rowText = truncateText(bodyFont, state.items[itemIndex].text, textRect.w)
addTextNode(
result,
z,
rootIdx,
bodyFont,
textRect,
rowText,
if state.items[itemIndex].done: mutedTextColor else: textColor,
Left,
Middle,
)
addTextNode(
result,
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