Skip to content

Commit bc581ab

Browse files
committed
Add RadialMenu custom widget example
Implement an interactive radial menu widget to demonstrate creating custom ImGui widgets using the draw list API. The widget features: - Circular pie menu with dynamic sector count - Mouse hover detection with visual feedback - Click handling with React callbacks - Path-based drawing for smooth filled sectors - Text rendering with automatic centering - Efficient rendering with proper draw order Implementation: - lib/imgui-unit/renderer.js: renderRadialMenu() function - examples/custom-widget/: New example application - README.md: Documentation on creating custom widgets The example shows how to use ImGui's immediate-mode drawing API (_ImDrawList_*), handle mouse interaction, and integrate with React's component model. Custom widgets use _igDummy() to reserve layout space and are integrated via the switch statement in renderNode().
1 parent 195b370 commit bc581ab

File tree

8 files changed

+302
-0
lines changed

8 files changed

+302
-0
lines changed

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ _**Note**: This project is an independent experiment and is not affiliated with,
4444
- [Table Components](#table-components)
4545
- [Drawing Primitives](#drawing-primitives)
4646
- [Adding New Components](#adding-new-components)
47+
- [Creating Custom Widgets](#creating-custom-widgets)
4748
- [Architecture](#architecture)
4849
- [Three-Unit Architecture](#three-unit-architecture)
4950
- [Component Overview](#component-overview)
@@ -765,6 +766,62 @@ const [enabled, setEnabled] = useState(false);
765766

766767
The FFI layer provides access to the entire Dear ImGui API, so you can expose any widget you need!
767768

769+
### Creating Custom Widgets
770+
771+
While you can compose existing React components together, Dear ImGui's real power lies in creating fully custom widgets using its immediate-mode drawing API. The [`examples/custom-widget/`](examples/custom-widget/) directory demonstrates this with an interactive radial menu.
772+
773+
![Custom Widget Screenshot](media/custom-widget.jpg)
774+
775+
**How custom widgets work:**
776+
777+
1. **Add a render function** in [`lib/imgui-unit/renderer.js`](lib/imgui-unit/renderer.js) that uses ImGui's draw list API
778+
2. **Add a case** to the switch statement in `renderNode()`
779+
3. **Use `_igDummy()`** to reserve layout space for your custom drawing
780+
4. **Handle mouse interaction** using `_igGetMousePos()` and `_igIsMouseClicked_Bool()`
781+
782+
**Example: RadialMenu** ([`lib/imgui-unit/renderer.js:607-752`](lib/imgui-unit/renderer.js#L607-L752))
783+
784+
```javascript
785+
function renderRadialMenu(node: any, vec2: c_ptr): void {
786+
const drawList = _igGetWindowDrawList();
787+
788+
// Get cursor position and calculate drawing coordinates
789+
_igGetCursorScreenPos(vec2);
790+
const centerX = +get_ImVec2_x(vec2) + radius;
791+
const centerY = +get_ImVec2_y(vec2) + radius;
792+
793+
...
794+
795+
// Draw filled sectors using path API
796+
_ImDrawList_PathClear(drawList);
797+
_ImDrawList_PathLineTo(drawList, centerPtr);
798+
_ImDrawList_PathArcTo(drawList, centerPtr, radius, angleStart, angleEnd, 32);
799+
_ImDrawList_PathFillConvex(drawList, color);
800+
801+
...
802+
803+
// Reserve space in layout
804+
set_ImVec2_x(vec2, radius * 2);
805+
set_ImVec2_y(vec2, radius * 2);
806+
_igDummy(vec2);
807+
}
808+
```
809+
810+
**Key ImGui drawing functions:**
811+
812+
- `_ImDrawList_AddLine()`, `_ImDrawList_AddCircle()`, `_ImDrawList_AddRect()` - Basic shapes
813+
- `_ImDrawList_PathArcTo()`, `_ImDrawList_PathFillConvex()` - Complex paths
814+
- `_ImDrawList_AddText_Vec2()` - Text at specific positions
815+
- `_igCalcTextSize()` - Measure text for centering
816+
- `_igGetMousePos()`, `_igIsMouseClicked_Bool()` - Mouse interaction
817+
818+
**See the full implementation:**
819+
820+
- Code: [`lib/imgui-unit/renderer.js`](lib/imgui-unit/renderer.js) (`renderRadialMenu()`)
821+
- Usage: [`examples/custom-widget/app.jsx`](examples/custom-widget/app.jsx)
822+
823+
This pattern works for any custom widget - graphs, charts, dials, custom controls, visualizations, etc. The draw list API gives you complete control over rendering while ImGui handles the window system and input.
824+
768825
## Architecture
769826

770827
### Three-Unit Architecture

examples/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@
66
add_subdirectory(hello)
77
add_subdirectory(dynamic-windows)
88
add_subdirectory(showcase)
9+
add_subdirectory(custom-widget)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Custom Widget Example
2+
3+
add_react_imgui_app(
4+
TARGET custom-widget
5+
ENTRY_POINT index.jsx
6+
SOURCES custom-widget.cpp
7+
)

examples/custom-widget/app.jsx

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React, { useState } from "react";
2+
3+
const ACTIONS = ["Cut", "Copy", "Paste", "Delete", "Save"];
4+
5+
export function App() {
6+
const [menuOpen, setMenuOpen] = useState(false);
7+
const [selectedAction, setSelectedAction] = useState(null);
8+
9+
const handleItemClick = (index) => {
10+
setSelectedAction(ACTIONS[index]);
11+
setMenuOpen(false);
12+
};
13+
14+
return (
15+
<window title="Custom Widget Example" defaultWidth={600} defaultHeight={400}>
16+
<text>This example demonstrates creating custom ImGui widgets.</text>
17+
<text>The RadialMenu is a custom widget built using ImGui's draw list API.</text>
18+
<separator />
19+
20+
<text>Click the button to open the radial menu:</text>
21+
<button onClick={() => setMenuOpen(!menuOpen)}>
22+
{menuOpen ? "Close Menu" : "Open Radial Menu"}
23+
</button>
24+
25+
{menuOpen && (
26+
<>
27+
<separator />
28+
<radialmenu
29+
radius={80}
30+
items={ACTIONS}
31+
onItemClick={handleItemClick}
32+
centerText="Actions"
33+
/>
34+
<separator />
35+
</>
36+
)}
37+
38+
{selectedAction && (
39+
<>
40+
<text color="#00FF88">Selected action: {selectedAction}</text>
41+
<separator />
42+
</>
43+
)}
44+
45+
<text color="#888888">Hover over menu sectors to highlight them.</text>
46+
<text color="#888888">Click a sector to select an action.</text>
47+
</window>
48+
);
49+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Custom Widget Example - Demonstrates custom ImGui widget creation
2+
// This example shows how to create custom widgets using ImGui's draw list API
3+
4+
#define PROVIDE_IMGUI_MAIN
5+
#include "imgui-runtime.h"

examples/custom-widget/index.jsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from "react";
2+
import { createRoot, render } from "react-imgui-reconciler/reconciler.js";
3+
import { App } from "./app.jsx";
4+
5+
// Create React root with fiber root and container
6+
const root = createRoot();
7+
8+
// Expose to typed unit via global
9+
globalThis.reactApp = {
10+
rootChildren: [],
11+
12+
// Render the app
13+
render() {
14+
render(React.createElement(App), root);
15+
}
16+
};
17+
18+
// Initial render
19+
globalThis.reactApp.render();

lib/imgui-unit/renderer.js

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,166 @@ function renderCircle(node: any, vec2: c_ptr): void {
600600
}
601601
}
602602

603+
/**
604+
* Renders a radial menu component - a circular menu with selectable sectors.
605+
* This demonstrates creating custom interactive widgets using ImGui's draw list API.
606+
*/
607+
function renderRadialMenu(node: any, vec2: c_ptr): void {
608+
const props = node.props;
609+
const drawList = _igGetWindowDrawList();
610+
611+
// Get menu properties
612+
const menuRadius = validateNumber((props && props.radius !== undefined) ? props.radius : 80, 80, "radialmenu radius");
613+
const innerRadius = menuRadius * 0.3; // Inner circle is 30% of outer radius
614+
615+
// Get items array - return early if not provided
616+
if (!props || !props.items || !Array.isArray(props.items)) {
617+
return;
618+
}
619+
const items: any = props.items;
620+
const itemCount = items.length;
621+
if (itemCount === 0) return;
622+
623+
// Get window cursor position - this is where we'll draw
624+
_igGetCursorScreenPos(vec2);
625+
const winX = +get_ImVec2_x(vec2);
626+
const winY = +get_ImVec2_y(vec2);
627+
628+
// Menu center is offset from top-left by radius (so full circle is visible)
629+
const centerX = winX + menuRadius;
630+
const centerY = winY + menuRadius;
631+
632+
// Get mouse position for hover detection
633+
_igGetMousePos(vec2);
634+
const mouseX = +get_ImVec2_x(vec2);
635+
const mouseY = +get_ImVec2_y(vec2);
636+
637+
// Calculate mouse position relative to menu center
638+
const dx = mouseX - centerX;
639+
const dy = mouseY - centerY;
640+
const mouseDist = Math.sqrt(dx * dx + dy * dy);
641+
const mouseAngle = Math.atan2(dy, dx);
642+
643+
// Calculate which sector the mouse is hovering over (-1 if none)
644+
let hoveredSector = -1;
645+
if (mouseDist >= innerRadius && mouseDist <= menuRadius) {
646+
// Adjust mouse angle to match sector drawing (which starts at -PI/2, top of circle)
647+
// atan2 returns 0 at right (east), we need 0 at top (north)
648+
let adjustedAngle = mouseAngle + 1.57079632679; // Add PI/2 to shift origin to top
649+
650+
// Normalize angle to 0-2π range
651+
if (adjustedAngle < 0) adjustedAngle += 6.28318530718; // 2*PI
652+
653+
// Calculate sector index (0 starts at top, goes clockwise)
654+
const anglePerSector = 6.28318530718 / itemCount; // 2*PI / itemCount
655+
hoveredSector = Math.floor(adjustedAngle / anglePerSector);
656+
}
657+
658+
// Check for clicks
659+
const wasClicked = _igIsMouseClicked_Bool(_ImGuiMouseButton_Left, 0);
660+
661+
// Color palette
662+
const baseColor = 0xFF444444; // Dark gray
663+
const hoverColor = 0xFF666666; // Lighter gray
664+
const borderColor = 0xFF888888; // Light gray border
665+
const textColor = 0xFFFFFFFF; // White text
666+
667+
// Allocate center point buffer
668+
const centerPtr = allocTmp(_sizeof_ImVec2);
669+
set_ImVec2_x(centerPtr, centerX);
670+
set_ImVec2_y(centerPtr, centerY);
671+
672+
const anglePerSector = 6.28318530718 / itemCount; // 2*PI / itemCount
673+
674+
// First pass: Draw all filled sectors and outlines
675+
for (let i = 0; i < itemCount; i++) {
676+
const angleStart = i * anglePerSector - 1.57079632679; // Start at top (-PI/2)
677+
const angleEnd = (i + 1) * anglePerSector - 1.57079632679;
678+
679+
// Choose color based on hover state
680+
const sectorColor = (i === hoveredSector) ? hoverColor : baseColor;
681+
682+
// Draw filled sector using path API
683+
_ImDrawList_PathClear(drawList);
684+
_ImDrawList_PathLineTo(drawList, centerPtr); // Start at center
685+
_ImDrawList_PathArcTo(drawList, centerPtr, menuRadius, angleStart, angleEnd, 32);
686+
_ImDrawList_PathLineTo(drawList, centerPtr); // Back to center
687+
_ImDrawList_PathFillConvex(drawList, sectorColor);
688+
689+
// Draw sector outline
690+
_ImDrawList_PathClear(drawList);
691+
_ImDrawList_PathArcTo(drawList, centerPtr, menuRadius, angleStart, angleEnd, 32);
692+
_ImDrawList_PathStroke(drawList, borderColor, 0, 1.0);
693+
}
694+
695+
// Second pass: Draw radial lines on top
696+
for (let i = 0; i < itemCount; i++) {
697+
const angleEnd = (i + 1) * anglePerSector - 1.57079632679;
698+
const lineAngle = angleEnd;
699+
const lineStartX = centerX + Math.cos(lineAngle) * innerRadius;
700+
const lineStartY = centerY + Math.sin(lineAngle) * innerRadius;
701+
const lineEndX = centerX + Math.cos(lineAngle) * menuRadius;
702+
const lineEndY = centerY + Math.sin(lineAngle) * menuRadius;
703+
704+
set_ImVec2_x(vec2, lineStartX);
705+
set_ImVec2_y(vec2, lineStartY);
706+
const lineEnd = allocTmp(_sizeof_ImVec2);
707+
set_ImVec2_x(lineEnd, lineEndX);
708+
set_ImVec2_y(lineEnd, lineEndY);
709+
_ImDrawList_AddLine(drawList, vec2, lineEnd, borderColor, 1.0);
710+
}
711+
712+
// Third pass: Draw text labels and handle clicks
713+
for (let i = 0; i < itemCount; i++) {
714+
const angleStart = i * anglePerSector - 1.57079632679;
715+
const labelAngle = angleStart + anglePerSector / 2.0;
716+
const labelRadius = innerRadius + (menuRadius - innerRadius) * 0.6;
717+
const labelX = centerX + Math.cos(labelAngle) * labelRadius;
718+
const labelY = centerY + Math.sin(labelAngle) * labelRadius;
719+
720+
// Calculate text size for centering
721+
const labelText = String(items[i]);
722+
const textSizePtr = allocTmp(_sizeof_ImVec2);
723+
_igCalcTextSize(textSizePtr, tmpUtf8(labelText), c_null, 0, -1.0);
724+
const textWidth = +get_ImVec2_x(textSizePtr);
725+
const textHeight = +get_ImVec2_y(textSizePtr);
726+
727+
// Draw centered text
728+
set_ImVec2_x(vec2, labelX - textWidth / 2.0);
729+
set_ImVec2_y(vec2, labelY - textHeight / 2.0);
730+
_ImDrawList_AddText_Vec2(drawList, vec2, textColor, tmpUtf8(labelText), c_null);
731+
732+
// Handle click on this sector
733+
if (wasClicked && i === hoveredSector) {
734+
safeInvokeCallback(node.props.onItemClick, i);
735+
}
736+
}
737+
738+
// Draw inner circle (center)
739+
const centerCircleColor = 0xFF333333;
740+
_ImDrawList_AddCircleFilled(drawList, centerPtr, innerRadius, centerCircleColor, 32);
741+
_ImDrawList_AddCircle(drawList, centerPtr, innerRadius, borderColor, 32, 1.0);
742+
743+
// Draw center text if provided
744+
const centerText = (props && props.centerText) ? String(props.centerText) : "";
745+
if (centerText !== "") {
746+
const centerTextSizePtr = allocTmp(_sizeof_ImVec2);
747+
_igCalcTextSize(centerTextSizePtr, tmpUtf8(centerText), c_null, 0, -1.0);
748+
const centerTextWidth = +get_ImVec2_x(centerTextSizePtr);
749+
const centerTextHeight = +get_ImVec2_y(centerTextSizePtr);
750+
751+
set_ImVec2_x(vec2, centerX - centerTextWidth / 2.0);
752+
set_ImVec2_y(vec2, centerY - centerTextHeight / 2.0);
753+
_ImDrawList_AddText_Vec2(drawList, vec2, textColor, tmpUtf8(centerText), c_null);
754+
}
755+
756+
// Advance cursor to reserve space
757+
const menuDiameter = menuRadius * 2;
758+
set_ImVec2_x(vec2, menuDiameter);
759+
set_ImVec2_y(vec2, menuDiameter);
760+
_igDummy(vec2);
761+
}
762+
603763
// Tree traversal and rendering
604764
function renderNode(node: any): void {
605765
if (!node) return;
@@ -691,6 +851,10 @@ function renderNode(node: any): void {
691851
renderCircle(node, vec2);
692852
break;
693853

854+
case "radialmenu":
855+
renderRadialMenu(node, vec2);
856+
break;
857+
694858
default:
695859
// Unknown type - just render children
696860
if (node.children) {

media/custom-widget.jpg

22 KB
Loading

0 commit comments

Comments
 (0)