Last active
April 13, 2026 18:41
-
-
Save planetis-m/b36387670ee6e76237c5e5d15f8cba69 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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