Created
April 13, 2026 18:50
-
-
Save planetis-m/908c859199cd89e6087082a87628d4a0 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, 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