diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ce5aef9 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,113 @@ +name: Build + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + workflow_dispatch: + +env: + HERMES_VERSION: 80359d48dbf0a108031d69c8a22bad180cfb4df3 + +jobs: + build: + name: ${{ matrix.os }} (${{ matrix.build_type }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + build_type: [Debug, Release] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Ubuntu dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + libx11-dev \ + libxi-dev \ + libxcursor-dev \ + libgl1-mesa-dev \ + libicu-dev \ + clang \ + cmake \ + ninja-build \ + nodejs \ + npm + + - name: Install macOS dependencies + if: runner.os == 'macOS' + run: | + brew install cmake ninja node + + - name: Cache Hermes build + id: hermes-cache + uses: actions/cache@v4 + with: + path: | + hermes-src + hermes-build + key: ${{ runner.os }}-hermes-${{ env.HERMES_VERSION }} + + - name: Build Hermes (if not cached) + if: steps.hermes-cache.outputs.cache-hit != 'true' + run: | + git clone https://github.com/facebook/hermes.git hermes-src + cd hermes-src + git checkout ${{ env.HERMES_VERSION }} + # Remove .git directory immediately to save ~100MB in cache + rm -rf .git + cd .. + cmake -S hermes-src -B hermes-build -DCMAKE_BUILD_TYPE=Release -G Ninja + cmake --build hermes-build --config Release + + - name: Install npm dependencies + run: npm install + + - name: Configure project (Ubuntu) + if: runner.os == 'Linux' + run: | + cmake -B build \ + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ + -DCMAKE_C_COMPILER=clang \ + -DCMAKE_CXX_COMPILER=clang++ \ + -DHERMES_BUILD_DIR=${{ github.workspace }}/hermes-build \ + -G Ninja + + - name: Configure project (macOS) + if: runner.os == 'macOS' + run: | + cmake -B build \ + -DCMAKE_BUILD_TYPE=${{ matrix.build_type }} \ + -DHERMES_BUILD_DIR=${{ github.workspace }}/hermes-build \ + -G Ninja + + - name: Build project + run: cmake --build build + + - name: Upload showcase executable + uses: actions/upload-artifact@v4 + with: + name: showcase-${{ matrix.os }}-${{ matrix.build_type }} + path: build/examples/showcase/showcase${{ runner.os == 'Windows' && '.exe' || '' }} + if-no-files-found: error + + - name: Upload hello executable + uses: actions/upload-artifact@v4 + with: + name: hello-${{ matrix.os }}-${{ matrix.build_type }} + path: build/examples/hello/hello${{ runner.os == 'Windows' && '.exe' || '' }} + if-no-files-found: error + + - name: Upload dynamic-windows executable + uses: actions/upload-artifact@v4 + with: + name: dynamic-windows-${{ matrix.os }}-${{ matrix.build_type }} + path: build/examples/dynamic-windows/dynamic-windows${{ runner.os == 'Windows' && '.exe' || '' }} + if-no-files-found: error diff --git a/README.md b/README.md index 3fac0ba..2a64a5a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,7 @@ _**Note**: This project is an independent experiment and is not affiliated with, - [Table Components](#table-components) - [Drawing Primitives](#drawing-primitives) - [Adding New Components](#adding-new-components) + - [Creating Custom Widgets](#creating-custom-widgets) - [Architecture](#architecture) - [Three-Unit Architecture](#three-unit-architecture) - [Component Overview](#component-overview) @@ -763,6 +764,62 @@ const [enabled, setEnabled] = useState(false); The FFI layer provides access to the entire Dear ImGui API, so you can expose any widget you need! +### Creating Custom Widgets + +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. + +![Custom Widget Screenshot](media/custom-widget.jpg) + +**How custom widgets work:** + +1. **Add a render function** in [`lib/imgui-unit/renderer.js`](lib/imgui-unit/renderer.js) that uses ImGui's draw list API +2. **Add a case** to the switch statement in [`renderNode()`](a86e558b6ea712179d955f04a6ec17493b18953a/lib/imgui-unit/renderer.js#L785) +3. **Use `_igDummy()`** to reserve layout space for your custom drawing +4. **Handle mouse interaction** using `_igGetMousePos()` and `_igIsMouseClicked_Bool()` + +**Example: RadialMenu** ([`lib/imgui-unit/renderer.js:607-752`](a86e558b6ea712179d955f04a6ec17493b18953a/lib/imgui-unit/renderer.js#L607-L752) + +```javascript +function renderRadialMenu(node: any, vec2: c_ptr): void { + const drawList = _igGetWindowDrawList(); + + // Get cursor position and calculate drawing coordinates + _igGetCursorScreenPos(vec2); + const centerX = +get_ImVec2_x(vec2) + radius; + const centerY = +get_ImVec2_y(vec2) + radius; + + ... + + // Draw filled sectors using path API + _ImDrawList_PathClear(drawList); + _ImDrawList_PathLineTo(drawList, centerPtr); + _ImDrawList_PathArcTo(drawList, centerPtr, radius, angleStart, angleEnd, 32); + _ImDrawList_PathFillConvex(drawList, color); + + ... + + // Reserve space in layout + set_ImVec2_x(vec2, radius * 2); + set_ImVec2_y(vec2, radius * 2); + _igDummy(vec2); +} +``` + +**Key ImGui drawing functions:** + +- `_ImDrawList_AddLine()`, `_ImDrawList_AddCircle()`, `_ImDrawList_AddRect()` - Basic shapes +- `_ImDrawList_PathArcTo()`, `_ImDrawList_PathFillConvex()` - Complex paths +- `_ImDrawList_AddText_Vec2()` - Text at specific positions +- `_igCalcTextSize()` - Measure text for centering +- `_igGetMousePos()`, `_igIsMouseClicked_Bool()` - Mouse interaction + +**See the full implementation:** + +- Code: [`lib/imgui-unit/renderer.js`](lib/imgui-unit/renderer.js) (`renderRadialMenu()`) +- Usage: [`examples/custom-widget/app.jsx`](examples/custom-widget/app.jsx) + +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. + ## Architecture ### Three-Unit Architecture @@ -1028,7 +1085,14 @@ Hermes is **automatically** cloned and built as part of the CMake configuration ```bash cmake -B build -DHERMES_GIT_TAG=abc123def ``` -- **Always Release mode**: Hermes always builds in Release for optimal performance +- **Build type**: Set via `HERMES_BUILD_TYPE` (default: Release) + ```bash + # Build Hermes in Debug mode (useful for debugging shermes crashes) + cmake -B build -DHERMES_BUILD_TYPE=Debug + + # Or RelWithDebInfo (optimized but with debug symbols) + cmake -B build -DHERMES_BUILD_TYPE=RelWithDebInfo + ``` - **Per-config isolation**: Debug and Release builds get separate Hermes clones ### Building the Project diff --git a/cmake/HermesExternal.cmake b/cmake/HermesExternal.cmake index a27c6ac..af565ae 100644 --- a/cmake/HermesExternal.cmake +++ b/cmake/HermesExternal.cmake @@ -77,14 +77,20 @@ else() CACHE STRING "Hermes git repository URL") set(HERMES_GIT_TAG "80359d48dbf0a108031d69c8a22bad180cfb4df3" CACHE STRING "Hermes git tag/commit/branch to build") + set(HERMES_BUILD_TYPE "Release" + CACHE STRING "Hermes build type (Release, Debug, RelWithDebInfo, MinSizeRel)") # Set Hermes paths - everything in build directory set(HERMES_SRC "${CMAKE_BINARY_DIR}/hermes-src") set(HERMES_BUILD "${CMAKE_BINARY_DIR}/hermes") + message(STATUS "Hermes git URL: ${HERMES_GIT_URL}") + message(STATUS "Hermes git tag: ${HERMES_GIT_TAG}") + message(STATUS "Hermes build type: ${HERMES_BUILD_TYPE}") + # Configure Hermes build options set(HERMES_CMAKE_ARGS - -DCMAKE_BUILD_TYPE=Release + -DCMAKE_BUILD_TYPE=${HERMES_BUILD_TYPE} -DHERMES_ENABLE_TEST_SUITE=OFF -DHERMES_BUILD_APPLE_FRAMEWORK=OFF -DCMAKE_C_COMPILER=${CMAKE_C_COMPILER} diff --git a/cmake/hermes.cmake b/cmake/hermes.cmake index b7ec800..7e88d1f 100644 --- a/cmake/hermes.cmake +++ b/cmake/hermes.cmake @@ -74,6 +74,7 @@ function(hermes_compile_native) $<$:-g3> --exported-unit=${ARG_UNIT_NAME} -Xes6-block-scoping + -Xline-directives ) if(ARG_FLAGS) list(APPEND COMPILER_FLAGS ${ARG_FLAGS}) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index c5e7ac4..98d6d9e 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -6,3 +6,4 @@ add_subdirectory(hello) add_subdirectory(dynamic-windows) add_subdirectory(showcase) +add_subdirectory(custom-widget) diff --git a/examples/custom-widget/CMakeLists.txt b/examples/custom-widget/CMakeLists.txt new file mode 100644 index 0000000..d13d43c --- /dev/null +++ b/examples/custom-widget/CMakeLists.txt @@ -0,0 +1,7 @@ +# Custom Widget Example + +add_react_imgui_app( + TARGET custom-widget + ENTRY_POINT index.jsx + SOURCES custom-widget.cpp +) diff --git a/examples/custom-widget/app.jsx b/examples/custom-widget/app.jsx new file mode 100644 index 0000000..a0b5568 --- /dev/null +++ b/examples/custom-widget/app.jsx @@ -0,0 +1,49 @@ +import React, { useState } from "react"; + +const ACTIONS = ["Cut", "Copy", "Paste", "Delete", "Save"]; + +export function App() { + const [menuOpen, setMenuOpen] = useState(false); + const [selectedAction, setSelectedAction] = useState(null); + + const handleItemClick = (index) => { + setSelectedAction(ACTIONS[index]); + setMenuOpen(false); + }; + + return ( + + This example demonstrates creating custom ImGui widgets. + The RadialMenu is a custom widget built using ImGui's draw list API. + + + Click the button to open the radial menu: + + + {menuOpen && ( + <> + + + + + )} + + {selectedAction && ( + <> + Selected action: {selectedAction} + + + )} + + Hover over menu sectors to highlight them. + Click a sector to select an action. + + ); +} diff --git a/examples/custom-widget/custom-widget.cpp b/examples/custom-widget/custom-widget.cpp new file mode 100644 index 0000000..b1b026e --- /dev/null +++ b/examples/custom-widget/custom-widget.cpp @@ -0,0 +1,5 @@ +// Custom Widget Example - Demonstrates custom ImGui widget creation +// This example shows how to create custom widgets using ImGui's draw list API + +#define PROVIDE_IMGUI_MAIN +#include "imgui-runtime.h" diff --git a/examples/custom-widget/index.jsx b/examples/custom-widget/index.jsx new file mode 100644 index 0000000..99d518d --- /dev/null +++ b/examples/custom-widget/index.jsx @@ -0,0 +1,19 @@ +import React from "react"; +import { createRoot, render } from "react-imgui-reconciler/reconciler.js"; +import { App } from "./app.jsx"; + +// Create React root with fiber root and container +const root = createRoot(); + +// Expose to typed unit via global +globalThis.reactApp = { + rootChildren: [], + + // Render the app + render() { + render(React.createElement(App), root); + } +}; + +// Initial render +globalThis.reactApp.render(); diff --git a/lib/imgui-unit/renderer.js b/lib/imgui-unit/renderer.js index 9cd940e..32721c2 100644 --- a/lib/imgui-unit/renderer.js +++ b/lib/imgui-unit/renderer.js @@ -600,6 +600,166 @@ function renderCircle(node: any, vec2: c_ptr): void { } } +/** + * Renders a radial menu component - a circular menu with selectable sectors. + * This demonstrates creating custom interactive widgets using ImGui's draw list API. + */ +function renderRadialMenu(node: any, vec2: c_ptr): void { + const props = node.props; + const drawList = _igGetWindowDrawList(); + + // Get menu properties + const menuRadius = validateNumber((props && props.radius !== undefined) ? props.radius : 80, 80, "radialmenu radius"); + const innerRadius = menuRadius * 0.3; // Inner circle is 30% of outer radius + + // Get items array - return early if not provided + if (!props || !props.items || !Array.isArray(props.items)) { + return; + } + const items: any = props.items; + const itemCount = items.length; + if (itemCount === 0) return; + + // Get window cursor position - this is where we'll draw + _igGetCursorScreenPos(vec2); + const winX = +get_ImVec2_x(vec2); + const winY = +get_ImVec2_y(vec2); + + // Menu center is offset from top-left by radius (so full circle is visible) + const centerX = winX + menuRadius; + const centerY = winY + menuRadius; + + // Get mouse position for hover detection + _igGetMousePos(vec2); + const mouseX = +get_ImVec2_x(vec2); + const mouseY = +get_ImVec2_y(vec2); + + // Calculate mouse position relative to menu center + const dx = mouseX - centerX; + const dy = mouseY - centerY; + const mouseDist = Math.sqrt(dx * dx + dy * dy); + const mouseAngle = Math.atan2(dy, dx); + + // Calculate which sector the mouse is hovering over (-1 if none) + let hoveredSector = -1; + if (mouseDist >= innerRadius && mouseDist <= menuRadius) { + // Adjust mouse angle to match sector drawing (which starts at -PI/2, top of circle) + // atan2 returns 0 at right (east), we need 0 at top (north) + let adjustedAngle = mouseAngle + 1.57079632679; // Add PI/2 to shift origin to top + + // Normalize angle to 0-2π range + if (adjustedAngle < 0) adjustedAngle += 6.28318530718; // 2*PI + + // Calculate sector index (0 starts at top, goes clockwise) + const anglePerSector = 6.28318530718 / itemCount; // 2*PI / itemCount + hoveredSector = Math.floor(adjustedAngle / anglePerSector); + } + + // Check for clicks + const wasClicked = _igIsMouseClicked_Bool(_ImGuiMouseButton_Left, 0); + + // Color palette + const baseColor = 0xFF444444; // Dark gray + const hoverColor = 0xFF666666; // Lighter gray + const borderColor = 0xFF888888; // Light gray border + const textColor = 0xFFFFFFFF; // White text + + // Allocate center point buffer + const centerPtr = allocTmp(_sizeof_ImVec2); + set_ImVec2_x(centerPtr, centerX); + set_ImVec2_y(centerPtr, centerY); + + const anglePerSector = 6.28318530718 / itemCount; // 2*PI / itemCount + + // First pass: Draw all filled sectors and outlines + for (let i = 0; i < itemCount; i++) { + const angleStart = i * anglePerSector - 1.57079632679; // Start at top (-PI/2) + const angleEnd = (i + 1) * anglePerSector - 1.57079632679; + + // Choose color based on hover state + const sectorColor = (i === hoveredSector) ? hoverColor : baseColor; + + // Draw filled sector using path API + _ImDrawList_PathClear(drawList); + _ImDrawList_PathLineTo(drawList, centerPtr); // Start at center + _ImDrawList_PathArcTo(drawList, centerPtr, menuRadius, angleStart, angleEnd, 32); + _ImDrawList_PathLineTo(drawList, centerPtr); // Back to center + _ImDrawList_PathFillConvex(drawList, sectorColor); + + // Draw sector outline + _ImDrawList_PathClear(drawList); + _ImDrawList_PathArcTo(drawList, centerPtr, menuRadius, angleStart, angleEnd, 32); + _ImDrawList_PathStroke(drawList, borderColor, 0, 1.0); + } + + // Second pass: Draw radial lines on top + for (let i = 0; i < itemCount; i++) { + const angleEnd = (i + 1) * anglePerSector - 1.57079632679; + const lineAngle = angleEnd; + const lineStartX = centerX + Math.cos(lineAngle) * innerRadius; + const lineStartY = centerY + Math.sin(lineAngle) * innerRadius; + const lineEndX = centerX + Math.cos(lineAngle) * menuRadius; + const lineEndY = centerY + Math.sin(lineAngle) * menuRadius; + + set_ImVec2_x(vec2, lineStartX); + set_ImVec2_y(vec2, lineStartY); + const lineEnd = allocTmp(_sizeof_ImVec2); + set_ImVec2_x(lineEnd, lineEndX); + set_ImVec2_y(lineEnd, lineEndY); + _ImDrawList_AddLine(drawList, vec2, lineEnd, borderColor, 1.0); + } + + // Third pass: Draw text labels and handle clicks + for (let i = 0; i < itemCount; i++) { + const angleStart = i * anglePerSector - 1.57079632679; + const labelAngle = angleStart + anglePerSector / 2.0; + const labelRadius = innerRadius + (menuRadius - innerRadius) * 0.6; + const labelX = centerX + Math.cos(labelAngle) * labelRadius; + const labelY = centerY + Math.sin(labelAngle) * labelRadius; + + // Calculate text size for centering + const labelText = String(items[i]); + const textSizePtr = allocTmp(_sizeof_ImVec2); + _igCalcTextSize(textSizePtr, tmpUtf8(labelText), c_null, 0, -1.0); + const textWidth = +get_ImVec2_x(textSizePtr); + const textHeight = +get_ImVec2_y(textSizePtr); + + // Draw centered text + set_ImVec2_x(vec2, labelX - textWidth / 2.0); + set_ImVec2_y(vec2, labelY - textHeight / 2.0); + _ImDrawList_AddText_Vec2(drawList, vec2, textColor, tmpUtf8(labelText), c_null); + + // Handle click on this sector + if (wasClicked && i === hoveredSector) { + safeInvokeCallback(node.props.onItemClick, i); + } + } + + // Draw inner circle (center) + const centerCircleColor = 0xFF333333; + _ImDrawList_AddCircleFilled(drawList, centerPtr, innerRadius, centerCircleColor, 32); + _ImDrawList_AddCircle(drawList, centerPtr, innerRadius, borderColor, 32, 1.0); + + // Draw center text if provided + const centerText = (props && props.centerText) ? String(props.centerText) : ""; + if (centerText !== "") { + const centerTextSizePtr = allocTmp(_sizeof_ImVec2); + _igCalcTextSize(centerTextSizePtr, tmpUtf8(centerText), c_null, 0, -1.0); + const centerTextWidth = +get_ImVec2_x(centerTextSizePtr); + const centerTextHeight = +get_ImVec2_y(centerTextSizePtr); + + set_ImVec2_x(vec2, centerX - centerTextWidth / 2.0); + set_ImVec2_y(vec2, centerY - centerTextHeight / 2.0); + _ImDrawList_AddText_Vec2(drawList, vec2, textColor, tmpUtf8(centerText), c_null); + } + + // Advance cursor to reserve space + const menuDiameter = menuRadius * 2; + set_ImVec2_x(vec2, menuDiameter); + set_ImVec2_y(vec2, menuDiameter); + _igDummy(vec2); +} + // Tree traversal and rendering function renderNode(node: any): void { if (!node) return; @@ -691,6 +851,10 @@ function renderNode(node: any): void { renderCircle(node, vec2); break; + case "radialmenu": + renderRadialMenu(node, vec2); + break; + default: // Unknown type - just render children if (node.children) { diff --git a/media/custom-widget.jpg b/media/custom-widget.jpg new file mode 100644 index 0000000..19573ce Binary files /dev/null and b/media/custom-widget.jpg differ