From 8c660dad54b5e51956dd187f15687eec7cba4442 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Thu, 11 Sep 2025 20:24:59 -0400 Subject: [PATCH 01/23] Simplify for example --- .../IRMenuItemManager.windows.cpp | 80 ++++--------------- .../IRMenuItemManager.windows.h | 41 ++++------ app/utils/useMenuItem.tsx | 3 +- 3 files changed, 35 insertions(+), 89 deletions(-) diff --git a/app/native/IRMenuItemManager/IRMenuItemManager.windows.cpp b/app/native/IRMenuItemManager/IRMenuItemManager.windows.cpp index 7be7d88..2df05f9 100644 --- a/app/native/IRMenuItemManager/IRMenuItemManager.windows.cpp +++ b/app/native/IRMenuItemManager/IRMenuItemManager.windows.cpp @@ -8,72 +8,26 @@ #include "pch.h" #include "IRMenuItemManager.windows.h" +using winrt::reactotron::implementation::IRMenuItemManager; + namespace winrt::reactotron::implementation { - IRMenuItemManager::IRMenuItemManager() noexcept - { - // TurboModule initialization - } - - Microsoft::ReactNative::JSValue IRMenuItemManager::getAvailableMenus() noexcept - { - // TODO: Get available Windows application menus - Microsoft::ReactNative::JSValueArray menus; - // Stub implementation - return Microsoft::ReactNative::JSValue(std::move(menus)); - } - - Microsoft::ReactNative::JSValue IRMenuItemManager::getMenuStructure() noexcept - { - // TODO: Get Windows application menu structure - Microsoft::ReactNative::JSValueArray structure; - // Stub implementation - return Microsoft::ReactNative::JSValue(std::move(structure)); - } - - void IRMenuItemManager::createMenu(std::string menuName, Microsoft::ReactNative::ReactPromise const &promise) noexcept + void IRMenuItemManager::createMenu(std::string menuName, + ::React::ReactPromise &&result) noexcept { - // TODO: Create a new Windows menu - Microsoft::ReactNative::JSValueObject result; - result["success"] = false; - result["existed"] = false; - result["menuName"] = menuName; - promise.Resolve(Microsoft::ReactNative::JSValue(std::move(result))); - } - - void IRMenuItemManager::addMenuItemAtPath(Microsoft::ReactNative::JSValue parentPath, std::string const &title, std::string const &keyEquivalent, Microsoft::ReactNative::ReactPromise const &promise) noexcept - { - // TODO: Add menu item at specified path in Windows - Microsoft::ReactNative::JSValueObject result; - result["success"] = false; - result["error"] = "Not implemented"; - promise.Resolve(Microsoft::ReactNative::JSValue(std::move(result))); - } - - void IRMenuItemManager::insertMenuItemAtPath(Microsoft::ReactNative::JSValue parentPath, std::string const &title, int atIndex, std::string const &keyEquivalent, Microsoft::ReactNative::ReactPromise const &promise) noexcept - { - // TODO: Insert menu item at specified index and path in Windows - Microsoft::ReactNative::JSValueObject result; - result["success"] = false; - result["error"] = "Not implemented"; - promise.Resolve(Microsoft::ReactNative::JSValue(std::move(result))); - } + // THE PROBLEM: onMenuItemPressed is nullptr/undefined at runtime + if (onMenuItemPressed) + { + PressEvent evt{}; + evt.menuPath = {"Test", "Event"}; + onMenuItemPressed(evt); + } - void IRMenuItemManager::removeMenuItemAtPath(Microsoft::ReactNative::JSValue path, Microsoft::ReactNative::ReactPromise const &promise) noexcept - { - // TODO: Remove menu item at specified path in Windows - Microsoft::ReactNative::JSValueObject result; - result["success"] = false; - result["error"] = "Not implemented"; - promise.Resolve(Microsoft::ReactNative::JSValue(std::move(result))); + CreateRet ret{}; + ret.success = true; + ret.existed = false; + ret.menuName = menuName; + result.Resolve(std::move(ret)); } - void IRMenuItemManager::setMenuItemEnabledAtPath(Microsoft::ReactNative::JSValue path, bool enabled, Microsoft::ReactNative::ReactPromise const &promise) noexcept - { - // TODO: Enable/disable menu item at specified path in Windows - Microsoft::ReactNative::JSValueObject result; - result["success"] = false; - result["error"] = "Not implemented"; - promise.Resolve(Microsoft::ReactNative::JSValue(std::move(result))); - } -} \ No newline at end of file +} // namespace winrt::reactotron::implementation diff --git a/app/native/IRMenuItemManager/IRMenuItemManager.windows.h b/app/native/IRMenuItemManager/IRMenuItemManager.windows.h index d320e1f..de8c0de 100644 --- a/app/native/IRMenuItemManager/IRMenuItemManager.windows.h +++ b/app/native/IRMenuItemManager/IRMenuItemManager.windows.h @@ -1,35 +1,28 @@ #pragma once -#include "NativeModules.h" + +#include +#include + +// Generated (DataTypes before Spec) +#include "..\..\..\windows\reactotron\codegen\NativeIRMenuItemManagerDataTypes.g.h" +#include "..\..\..\windows\reactotron\codegen\NativeIRMenuItemManagerSpec.g.h" namespace winrt::reactotron::implementation { REACT_MODULE(IRMenuItemManager) - struct IRMenuItemManager + struct IRMenuItemManager : reactotronCodegen::IRMenuItemManagerSpec { - IRMenuItemManager() noexcept; - - REACT_SYNC_METHOD(getAvailableMenus) - Microsoft::ReactNative::JSValue getAvailableMenus() noexcept; - - REACT_SYNC_METHOD(getMenuStructure) - Microsoft::ReactNative::JSValue getMenuStructure() noexcept; + // Only the essential types needed for the event + using PressEvent = reactotronCodegen::IRMenuItemManagerSpec_MenuItemPressedEvent; + using CreateRet = reactotronCodegen::IRMenuItemManagerSpec_createMenu_returnType; + // One simple method to test event emission REACT_METHOD(createMenu) - void createMenu(std::string menuName, Microsoft::ReactNative::ReactPromise const& promise) noexcept; - - REACT_METHOD(addMenuItemAtPath) - void addMenuItemAtPath(Microsoft::ReactNative::JSValue parentPath, std::string const& title, std::string const& keyEquivalent, Microsoft::ReactNative::ReactPromise const& promise) noexcept; - - REACT_METHOD(insertMenuItemAtPath) - void insertMenuItemAtPath(Microsoft::ReactNative::JSValue parentPath, std::string const& title, int atIndex, std::string const& keyEquivalent, Microsoft::ReactNative::ReactPromise const& promise) noexcept; - - REACT_METHOD(removeMenuItemAtPath) - void removeMenuItemAtPath(Microsoft::ReactNative::JSValue path, Microsoft::ReactNative::ReactPromise const& promise) noexcept; - - REACT_METHOD(setMenuItemEnabledAtPath) - void setMenuItemEnabledAtPath(Microsoft::ReactNative::JSValue path, bool enabled, Microsoft::ReactNative::ReactPromise const& promise) noexcept; + void createMenu(std::string menuName, ::React::ReactPromise &&result) noexcept; + // --- THE ISSUE: This event is undefined in JavaScript --- REACT_EVENT(onMenuItemPressed) - std::function onMenuItemPressed; + std::function onMenuItemPressed; }; -} \ No newline at end of file + +} // namespace winrt::reactotron::implementation diff --git a/app/utils/useMenuItem.tsx b/app/utils/useMenuItem.tsx index a680f05..7fc2536 100644 --- a/app/utils/useMenuItem.tsx +++ b/app/utils/useMenuItem.tsx @@ -57,7 +57,7 @@ import NativeIRMenuItemManager, { type MenuListEntry, SEPARATOR, } from "../native/IRMenuItemManager/NativeIRMenuItemManager" -import { Platform } from "react-native" +import { Alert, Platform } from "react-native" // Only thing to configure here is the path separator. const PATH_SEPARATOR = " > " @@ -288,7 +288,6 @@ export function useMenuItem(config?: MenuItemConfig) { }, [config, addEntries, removeMenuItems, getItemDifference]) useEffect(() => { - if (Platform.OS === "windows") return const subscription = NativeIRMenuItemManager.onMenuItemPressed(handleMenuItemPressed) discoverMenus() return () => { From ae2c078c7f307fbcf28f193e5ac3dc412422911d Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Wed, 17 Sep 2025 08:59:05 -0400 Subject: [PATCH 02/23] fix: include modules that annotate with REACT_TURBO_MODULE --- bin/generate_windows_native_files.js | 58 +++++++++++++--------------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/bin/generate_windows_native_files.js b/bin/generate_windows_native_files.js index 1266bdf..582dc34 100644 --- a/bin/generate_windows_native_files.js +++ b/bin/generate_windows_native_files.js @@ -10,22 +10,22 @@ const path = require("path") // Color constants for output (matching Ruby script exactly) const colors = process.env.NO_COLOR ? { - R: "", - RB: "", - G: "", - GB: "", - BB: "", - Y: "", - YB: "", - D: "", - DD: "", - DB: "", - DDB: "", - S: "", - X: "\x1b[0m", - } + R: "", + RB: "", + G: "", + GB: "", + BB: "", + Y: "", + YB: "", + D: "", + DD: "", + DB: "", + DDB: "", + S: "", + X: "\x1b[0m", + } : process.env.PREFERS_CONTRAST === "more" - ? { + ? { R: "\x1b[91m", RB: "\x1b[91m", G: "\x1b[92m", @@ -40,7 +40,7 @@ const colors = process.env.NO_COLOR S: "\x1b[9m", X: "\x1b[0m", } - : { + : { R: "\x1b[31m", RB: "\x1b[31;1m", G: "\x1b[32m", @@ -179,7 +179,7 @@ function findWindowsNativeFiles(appPath, projectRoot) { // Detect module type by examining the header file const headerContent = fs.readFileSync(module.files.h, "utf8") - if (headerContent.includes("REACT_MODULE")) { + if (headerContent.includes("REACT_MODULE") || headerContent.includes("REACT_TURBO_MODULE")) { module.type = "turbo" } else if ( headerContent.includes("RegisterIRTabNativeComponent") || @@ -199,8 +199,7 @@ function findWindowsNativeFiles(appPath, projectRoot) { ) } else { console.log( - `${colors.YB} ⚠ Warning ${colors.X}${colors.D}${name} missing ${ - module.files.h ? ".cpp" : ".h" + `${colors.YB} ⚠ Warning ${colors.X}${colors.D}${name} missing ${module.files.h ? ".cpp" : ".h" } file${colors.X}`, ) } @@ -228,16 +227,14 @@ function generateConsolidatedFiles(modules, windowsDir, projectRoot) { fs.writeFileSync(headerPath, headerContent) const relativePath = path.relative(projectRoot, headerPath) console.log( - `${colors.BB} ➕ Generated ${colors.X} ${colors.BB}IRNativeModules.g.h${colors.X} ${ - colors.DD + `${colors.BB} ➕ Generated ${colors.X} ${colors.BB}IRNativeModules.g.h${colors.X} ${colors.DD }${path.dirname(relativePath)}${colors.X}`, ) changesMade = true } else { const relativePath = path.relative(projectRoot, headerPath) console.log( - `${colors.DB} ✔️ Exists ${colors.X}${colors.DB}IRNativeModules.g.h${colors.X} ${ - colors.DD + `${colors.DB} ✔️ Exists ${colors.X}${colors.DB}IRNativeModules.g.h${colors.X} ${colors.DD }${path.dirname(relativePath)}${colors.X}`, ) } @@ -250,16 +247,14 @@ function generateConsolidatedFiles(modules, windowsDir, projectRoot) { fs.writeFileSync(cppPath, cppContent) const relativePath = path.relative(projectRoot, cppPath) console.log( - `${colors.BB} ➕ Generated ${colors.X} ${colors.BB}IRNativeModules.g.cpp${colors.X} ${ - colors.DD + `${colors.BB} ➕ Generated ${colors.X} ${colors.BB}IRNativeModules.g.cpp${colors.X} ${colors.DD }${path.dirname(relativePath)}${colors.X}`, ) changesMade = true } else { const relativePath = path.relative(projectRoot, cppPath) console.log( - `${colors.DB} ✔️ Exists ${colors.X}${colors.DB}IRNativeModules.g.cpp${colors.X} ${ - colors.DD + `${colors.DB} ✔️ Exists ${colors.X}${colors.DB}IRNativeModules.g.cpp${colors.X} ${colors.DD }${path.dirname(relativePath)}${colors.X}`, ) } @@ -307,11 +302,10 @@ function generateHeaderTemplate(modules, windowsDir) { // // TurboModules (${turboModules.length}) will be auto-registered by AddAttributedModules() // Fabric Components (${fabricComponents.length}) require manual registration calls -${ - unknownModules.length > 0 - ? `// Unknown modules (${unknownModules.length}) - please check their implementation` - : "" -} +${unknownModules.length > 0 + ? `// Unknown modules (${unknownModules.length}) - please check their implementation` + : "" + } ${allIncludes} From 12c9590270b61ac5ce875949a71af1d2c8958e4d Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Mon, 22 Sep 2025 12:00:12 -0400 Subject: [PATCH 03/23] use turbo module --- app/native/IRMenuItemManager/IRMenuItemManager.windows.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/native/IRMenuItemManager/IRMenuItemManager.windows.h b/app/native/IRMenuItemManager/IRMenuItemManager.windows.h index de8c0de..e15daf9 100644 --- a/app/native/IRMenuItemManager/IRMenuItemManager.windows.h +++ b/app/native/IRMenuItemManager/IRMenuItemManager.windows.h @@ -9,7 +9,7 @@ namespace winrt::reactotron::implementation { - REACT_MODULE(IRMenuItemManager) + REACT_TURBO_MODULE(IRMenuItemManager) struct IRMenuItemManager : reactotronCodegen::IRMenuItemManagerSpec { // Only the essential types needed for the event From 9a2c3c8d8f9226536100db63430bc8a4839c7303 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Tue, 23 Sep 2025 08:37:09 -0400 Subject: [PATCH 04/23] Fix fabric imports --- windows/reactotron/reactotron.cpp | 31 +++++++++++++++++++++++++-- windows/reactotron/reactotron.vcxproj | 1 + 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/windows/reactotron/reactotron.cpp b/windows/reactotron/reactotron.cpp index 970b96d..4754636 100644 --- a/windows/reactotron/reactotron.cpp +++ b/windows/reactotron/reactotron.cpp @@ -7,6 +7,12 @@ #include "AutolinkedNativeModules.g.h" #include "NativeModules.h" +#include "IRNativeModules.g.h" + +#include +#include + + // A PackageProvider containing any turbo modules you define within this app project struct CompReactPackageProvider @@ -14,6 +20,8 @@ struct CompReactPackageProvider public: // IReactPackageProvider void CreatePackage(winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder) noexcept { AddAttributedModules(packageBuilder, true); + // Register Fabric components directly + winrt::reactotron::implementation::RegisterAllFabricComponents(packageBuilder); } }; @@ -70,8 +78,27 @@ _Use_decl_annotations_ int CALLBACK WinMain(HINSTANCE instance, HINSTANCE, PSTR // Get the AppWindow so we can configure its initial title and size auto appWindow{reactNativeWin32App.AppWindow()}; - appWindow.Title(L"Reactotron"); - appWindow.Resize({1000, 1000}); + appWindow.Title(L""); + appWindow.Resize({2000, 1500}); + + + { + using namespace winrt::Microsoft::UI::Windowing; + + auto titleBar = appWindow.TitleBar(); + if (titleBar) + { + // Extend React Native content into the title bar area + titleBar.ExtendsContentIntoTitleBar(true); + + // Make system caption buttons transparent so they overlay our content + winrt::Windows::UI::Color transparent = winrt::Windows::UI::Colors::Transparent(); + titleBar.ButtonBackgroundColor(transparent); + titleBar.ButtonInactiveBackgroundColor(transparent); + titleBar.ButtonHoverBackgroundColor(transparent); + titleBar.ButtonPressedBackgroundColor(transparent); + } + } // Get the ReactViewOptions so we can set the initial RN component to load auto viewOptions{reactNativeWin32App.ReactViewOptions()}; diff --git a/windows/reactotron/reactotron.vcxproj b/windows/reactotron/reactotron.vcxproj index 41c0023..01a66bc 100644 --- a/windows/reactotron/reactotron.vcxproj +++ b/windows/reactotron/reactotron.vcxproj @@ -104,6 +104,7 @@ + Create From 35f6eac2349a6cb563548c04cec4b14bc659c5ce Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Tue, 23 Sep 2025 13:02:43 -0400 Subject: [PATCH 05/23] Add passthrough module --- .../PassthroughView/PassthroughView.tsx | 9 ++ .../IRPassthroughView.windows.cpp | 152 ++++++++++++++++++ .../IRPassthroughView.windows.h | 38 +++++ .../IRPassthroughViewNativeComponent.ts | 13 ++ windows/reactotron/IRNativeModules.g.cpp | 1 + windows/reactotron/IRNativeModules.g.h | 4 +- windows/reactotron/reactotron.cpp | 1 - 7 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 app/components/PassthroughView/PassthroughView.tsx create mode 100644 app/native/IRPassthroughView/IRPassthroughView.windows.cpp create mode 100644 app/native/IRPassthroughView/IRPassthroughView.windows.h create mode 100644 app/native/IRPassthroughView/IRPassthroughViewNativeComponent.ts diff --git a/app/components/PassthroughView/PassthroughView.tsx b/app/components/PassthroughView/PassthroughView.tsx new file mode 100644 index 0000000..11fbf4d --- /dev/null +++ b/app/components/PassthroughView/PassthroughView.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import type { ViewProps } from 'react-native'; +import NativePassthroughView from '../../native/IRPassthroughView/IRPassthroughViewNativeComponent'; + +export interface PassthroughViewProps extends ViewProps { } + +export const PassthroughView: React.FC = (props) => { + return ; +}; \ No newline at end of file diff --git a/app/native/IRPassthroughView/IRPassthroughView.windows.cpp b/app/native/IRPassthroughView/IRPassthroughView.windows.cpp new file mode 100644 index 0000000..c53fdff --- /dev/null +++ b/app/native/IRPassthroughView/IRPassthroughView.windows.cpp @@ -0,0 +1,152 @@ +#include "pch.h" + +#include "IRPassthroughView.windows.h" +#include +#include + +namespace winrt::reactotron::implementation { + +std::vector IRPassthroughView::s_instances; + +IRPassthroughView::IRPassthroughView() { + s_instances.push_back(this); +} + +IRPassthroughView::~IRPassthroughView() { + auto it = std::find(s_instances.begin(), s_instances.end(), this); + if (it != s_instances.end()) { + s_instances.erase(it); + UpdateAllPassthroughRegions(); + } +} + +void RegisterIRPassthroughNativeComponent( + winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder) noexcept { + reactotronCodegen::RegisterIRPassthroughNativeComponent( + packageBuilder, + [](const winrt::Microsoft::ReactNative::Composition::IReactCompositionViewComponentBuilder &builder) { + // Disable default border handling to prevent visual clipping issues + builder.SetViewFeatures( + winrt::Microsoft::ReactNative::Composition::ComponentViewFeatures::Default & + ~winrt::Microsoft::ReactNative::Composition::ComponentViewFeatures::NativeBorder); + }); +} + +winrt::Microsoft::UI::Composition::Visual IRPassthroughView::CreateVisual( + const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + auto compositor = view.as().Compositor(); + + m_visual = compositor.CreateSpriteVisual(); + m_view = view; + + // Create visual that can be styled from React Native + return m_visual; +} + +void IRPassthroughView::Initialize(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + m_view = view; + + m_layoutMetricChangedRevoker = view.LayoutMetricsChanged( + winrt::auto_revoke, + [wkThis = get_weak()]( + const winrt::IInspectable & /*sender*/, const winrt::Microsoft::ReactNative::LayoutMetricsChangedArgs &args) { + if (auto strongThis = wkThis.get()) { + auto visual = strongThis->m_visual; + + // Manually position visual since we disabled default border handling + visual.Size( + {args.NewLayoutMetrics().Frame.Width * args.NewLayoutMetrics().PointScaleFactor, + args.NewLayoutMetrics().Frame.Height * args.NewLayoutMetrics().PointScaleFactor}); + visual.Offset({ + args.NewLayoutMetrics().Frame.X * args.NewLayoutMetrics().PointScaleFactor, + args.NewLayoutMetrics().Frame.Y * args.NewLayoutMetrics().PointScaleFactor, + 0.0f, + }); + + // Update passthrough regions for title bar interaction + UpdateAllPassthroughRegions(); + } + }); +} + + + +winrt::Windows::Graphics::RectInt32 IRPassthroughView::GetPassthroughRect() const noexcept { + if (!m_visual || !m_view) { + return { 0, 0, 0, 0 }; + } + + auto size = m_visual.Size(); + auto offset = m_visual.Offset(); + + // Get rectangle coordinates for passthrough regions + return { + static_cast(offset.x), + static_cast(offset.y), + static_cast(size.x), + static_cast(size.y) + }; +} + +void IRPassthroughView::UpdateAllPassthroughRegions() noexcept { + try { + // Find the main window for our process using more reliable enumeration + HWND hwnd = nullptr; + EnumWindows([](HWND h, LPARAM p) -> BOOL { + DWORD pid = 0; + GetWindowThreadProcessId(h, &pid); + if (pid == GetCurrentProcessId() && IsWindowVisible(h) && !GetParent(h)) { + *reinterpret_cast(p) = h; + return FALSE; // Stop enumeration + } + return TRUE; // Continue enumeration + }, reinterpret_cast(&hwnd)); + + if (!hwnd) { + return; // No main window found + } + + // Convert HWND to AppWindow + auto windowId = winrt::Microsoft::UI::GetWindowIdFromWindow(hwnd); + auto appWindow = winrt::Microsoft::UI::Windowing::AppWindow::GetFromWindowId(windowId); + if (!appWindow) { + return; + } + + auto nonClientInputSrc = winrt::Microsoft::UI::Input::InputNonClientPointerSource::GetForWindowId(appWindow.Id()); + if (!nonClientInputSrc) { + return; + } + + // Collect rectangles from all PassthroughView instances + std::vector passthroughRects; + + for (auto* instance : s_instances) { + if (instance && instance->m_visual && instance->m_view) { + try { + auto rect = instance->GetPassthroughRect(); + if (rect.Width > 0 && rect.Height > 0) { + passthroughRects.push_back(rect); + } + } catch (...) { + #ifdef _DEBUG + OutputDebugStringA("[IRPassthroughView] Exception accessing instance during rect collection\n"); + #endif + } + } + } + + // Configure passthrough regions for interactive elements + nonClientInputSrc.SetRegionRects( + winrt::Microsoft::UI::Input::NonClientRegionKind::Passthrough, + passthroughRects + ); + + } catch (...) { + #ifdef _DEBUG + OutputDebugStringA("[IRPassthroughView] Exception in UpdateAllPassthroughRegions\n"); + #endif + } +} + +} // namespace winrt::reactotron::implementation \ No newline at end of file diff --git a/app/native/IRPassthroughView/IRPassthroughView.windows.h b/app/native/IRPassthroughView/IRPassthroughView.windows.h new file mode 100644 index 0000000..45ff8ab --- /dev/null +++ b/app/native/IRPassthroughView/IRPassthroughView.windows.h @@ -0,0 +1,38 @@ +#pragma once + +#include "..\..\..\windows\reactotron\codegen\react\components\AppSpec\IRPassthrough.g.h" +#include +#include +#include +#include + + +namespace winrt::reactotron::implementation +{ + + void RegisterIRPassthroughNativeComponent( + winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder) noexcept; + + struct IRPassthroughView : winrt::implements, + reactotronCodegen::BaseIRPassthrough + { + IRPassthroughView(); + ~IRPassthroughView(); + + winrt::Microsoft::UI::Composition::Visual CreateVisual( + const winrt::Microsoft::ReactNative::ComponentView &view) noexcept override; + void Initialize(const winrt::Microsoft::ReactNative::ComponentView & /*view*/) noexcept override; + + + private: + winrt::Microsoft::ReactNative::ComponentView::LayoutMetricsChanged_revoker m_layoutMetricChangedRevoker; + winrt::Microsoft::UI::Composition::SpriteVisual m_visual{nullptr}; + winrt::Microsoft::ReactNative::ComponentView m_view{nullptr}; + + static std::vector s_instances; + static void UpdateAllPassthroughRegions() noexcept; + + winrt::Windows::Graphics::RectInt32 GetPassthroughRect() const noexcept; + }; + +} // namespace winrt::reactotron::implementation \ No newline at end of file diff --git a/app/native/IRPassthroughView/IRPassthroughViewNativeComponent.ts b/app/native/IRPassthroughView/IRPassthroughViewNativeComponent.ts new file mode 100644 index 0000000..f02c743 --- /dev/null +++ b/app/native/IRPassthroughView/IRPassthroughViewNativeComponent.ts @@ -0,0 +1,13 @@ +import type { ViewProps } from "react-native" +import type { BubblingEventHandler } from "react-native/Libraries/Types/CodegenTypes" + +// For React Native 0.80, use this: +// import { codegenNativeComponent } from "react-native" +// For React Native 0.78, use this: +import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent" + +export interface NativeProps extends ViewProps { + // Add Passthrough specific props here if needed +} + +export default codegenNativeComponent("IRPassthrough") \ No newline at end of file diff --git a/windows/reactotron/IRNativeModules.g.cpp b/windows/reactotron/IRNativeModules.g.cpp index c3dfc1a..c58643e 100644 --- a/windows/reactotron/IRNativeModules.g.cpp +++ b/windows/reactotron/IRNativeModules.g.cpp @@ -4,6 +4,7 @@ namespace winrt::reactotron::implementation { void RegisterAllFabricComponents(winrt::Microsoft::ReactNative::IReactPackageBuilder const& packageBuilder) noexcept { // Auto-generated Fabric component registrations + RegisterIRPassthroughNativeComponent(packageBuilder); RegisterIRTabNativeComponent(packageBuilder); } } diff --git a/windows/reactotron/IRNativeModules.g.h b/windows/reactotron/IRNativeModules.g.h index 717ed49..867c32b 100644 --- a/windows/reactotron/IRNativeModules.g.h +++ b/windows/reactotron/IRNativeModules.g.h @@ -3,7 +3,7 @@ // DO NOT EDIT - This file is auto-generated // // TurboModules (9) will be auto-registered by AddAttributedModules() -// Fabric Components (1) require manual registration calls +// Fabric Components (2) require manual registration calls #include "../../app/native/IRActionMenuManager/IRActionMenuManager.windows.h" @@ -11,6 +11,7 @@ #include "../../app/native/IRFontList/IRFontList.windows.h" #include "../../app/native/IRKeyboard/IRKeyboard.windows.h" #include "../../app/native/IRMenuItemManager/IRMenuItemManager.windows.h" +#include "../../app/native/IRPassthroughView/IRPassthroughView.windows.h" #include "../../app/native/IRRunShellCommand/IRRunShellCommand.windows.h" #include "../../app/native/IRSystemInfo/IRSystemInfo.windows.h" #include "../../app/native/IRTabComponentView/IRTabComponentView.windows.h" @@ -19,6 +20,7 @@ namespace winrt::reactotron::implementation { // Fabric component registration functions + void RegisterIRPassthroughNativeComponent(winrt::Microsoft::ReactNative::IReactPackageBuilder const& packageBuilder) noexcept; void RegisterIRTabNativeComponent(winrt::Microsoft::ReactNative::IReactPackageBuilder const& packageBuilder) noexcept; // Helper function to register all Fabric components diff --git a/windows/reactotron/reactotron.cpp b/windows/reactotron/reactotron.cpp index 4754636..41543b6 100644 --- a/windows/reactotron/reactotron.cpp +++ b/windows/reactotron/reactotron.cpp @@ -13,7 +13,6 @@ #include - // A PackageProvider containing any turbo modules you define within this app project struct CompReactPackageProvider : winrt::implements { From c687230ada8276b4b370cbc563a17346cf1f3373 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Tue, 23 Sep 2025 13:07:08 -0400 Subject: [PATCH 06/23] Group titlebar components --- app/app.tsx | 14 ++++----- .../PassthroughView/PassthroughView.tsx | 9 ------ app/components/Titlebar/PassthroughView.tsx | 30 ++++++++++++++++++ app/components/{ => Titlebar}/Titlebar.tsx | 31 ++++++++++--------- 4 files changed, 54 insertions(+), 30 deletions(-) delete mode 100644 app/components/PassthroughView/PassthroughView.tsx create mode 100644 app/components/Titlebar/PassthroughView.tsx rename app/components/{ => Titlebar}/Titlebar.tsx (61%) diff --git a/app/app.tsx b/app/app.tsx index c8a0aa3..b79fd4f 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -10,7 +10,7 @@ import { useTheme, themed } from "./theme/theme" import { useEffect, useMemo } from "react" import { TimelineScreen } from "./screens/TimelineScreen" import { useMenuItem } from "./utils/useMenuItem" -import { Titlebar } from "./components/Titlebar" +import { Titlebar } from "./components/Titlebar/Titlebar" import { Sidebar } from "./components/Sidebar/Sidebar" import { useSidebar } from "./state/useSidebar" import { AppHeader } from "./components/AppHeader" @@ -73,12 +73,12 @@ function App(): React.JSX.Element { }, ...(__DEV__ ? [ - { - label: "Toggle Dev Menu", - shortcut: "cmd+shift+d", - action: () => NativeModules.DevMenu.show(), - }, - ] + { + label: "Toggle Dev Menu", + shortcut: "cmd+shift+d", + action: () => NativeModules.DevMenu.show(), + }, + ] : []), ], Window: [ diff --git a/app/components/PassthroughView/PassthroughView.tsx b/app/components/PassthroughView/PassthroughView.tsx deleted file mode 100644 index 11fbf4d..0000000 --- a/app/components/PassthroughView/PassthroughView.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import type { ViewProps } from 'react-native'; -import NativePassthroughView from '../../native/IRPassthroughView/IRPassthroughViewNativeComponent'; - -export interface PassthroughViewProps extends ViewProps { } - -export const PassthroughView: React.FC = (props) => { - return ; -}; \ No newline at end of file diff --git a/app/components/Titlebar/PassthroughView.tsx b/app/components/Titlebar/PassthroughView.tsx new file mode 100644 index 0000000..5740bfc --- /dev/null +++ b/app/components/Titlebar/PassthroughView.tsx @@ -0,0 +1,30 @@ +/** + * PassthroughView - Windows Title Bar Click-Through Component + * + * Creates regions within the extended title bar that allow mouse clicks to pass through + * to underlying interactive elements (buttons, inputs, etc.) while keeping the rest of + * the title bar draggable. + * + * This is necessary because Windows title bars with ExtendsContentIntoTitleBar(true) + * capture all mouse events by default. PassthroughView creates "punch-out" regions + * using Windows InputNonClientPointerSource passthrough regions. + * + * On macOS, this simply returns a regular View since macOS title bars don't intercept + * mouse events the same way - interactive elements in the title bar work normally. + * + * Usage: Wrap interactive elements that need to remain clickable in the title bar area. + * Example: + */ +import React from 'react'; +import { View, Platform } from 'react-native'; +import type { ViewProps } from 'react-native'; +import NativePassthroughView from '../../native/IRPassthroughView/IRPassthroughViewNativeComponent'; + +export interface PassthroughViewProps extends ViewProps { } + +export const PassthroughView: React.FC = (props) => { + return Platform.select({ + windows: , + default: , // macOS and other platforms use regular View + }); +}; \ No newline at end of file diff --git a/app/components/Titlebar.tsx b/app/components/Titlebar/Titlebar.tsx similarity index 61% rename from app/components/Titlebar.tsx rename to app/components/Titlebar/Titlebar.tsx index 9a683a3..c939596 100644 --- a/app/components/Titlebar.tsx +++ b/app/components/Titlebar/Titlebar.tsx @@ -1,8 +1,9 @@ -import { themed, useTheme } from "../theme/theme" +import { themed, useTheme } from "../../theme/theme" import { Platform, View, ViewStyle } from "react-native" -import { Icon } from "./Icon" -import ActionButton from "./ActionButton" -import { useSidebar } from "../state/useSidebar" +import { Icon } from "../Icon" +import ActionButton from "../ActionButton" +import { useSidebar } from "../../state/useSidebar" +import { PassthroughView } from "./PassthroughView" export const Titlebar = () => { const theme = useTheme() @@ -12,16 +13,18 @@ export const Titlebar = () => { - ( - - )} - onClick={toggleSidebar} - /> + + ( + + )} + onClick={toggleSidebar} + /> + ) From fb794cd3a0815a2a6394d5149ffd1e18b41dacb9 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Tue, 23 Sep 2025 13:13:28 -0400 Subject: [PATCH 07/23] Code style fixes --- app/components/Titlebar/PassthroughView.tsx | 15 ++++++--------- .../IRPassthroughViewNativeComponent.ts | 7 ++----- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/app/components/Titlebar/PassthroughView.tsx b/app/components/Titlebar/PassthroughView.tsx index 5740bfc..70639aa 100644 --- a/app/components/Titlebar/PassthroughView.tsx +++ b/app/components/Titlebar/PassthroughView.tsx @@ -15,16 +15,13 @@ * Usage: Wrap interactive elements that need to remain clickable in the title bar area. * Example: */ -import React from 'react'; -import { View, Platform } from 'react-native'; -import type { ViewProps } from 'react-native'; -import NativePassthroughView from '../../native/IRPassthroughView/IRPassthroughViewNativeComponent'; +import { View, Platform } from "react-native" +import type { ViewProps } from "react-native" +import NativePassthroughView from "../../native/IRPassthroughView/IRPassthroughViewNativeComponent" -export interface PassthroughViewProps extends ViewProps { } - -export const PassthroughView: React.FC = (props) => { +export const PassthroughView = (props: ViewProps) => { return Platform.select({ windows: , default: , // macOS and other platforms use regular View - }); -}; \ No newline at end of file + }) +} diff --git a/app/native/IRPassthroughView/IRPassthroughViewNativeComponent.ts b/app/native/IRPassthroughView/IRPassthroughViewNativeComponent.ts index f02c743..22a8581 100644 --- a/app/native/IRPassthroughView/IRPassthroughViewNativeComponent.ts +++ b/app/native/IRPassthroughView/IRPassthroughViewNativeComponent.ts @@ -1,13 +1,10 @@ import type { ViewProps } from "react-native" -import type { BubblingEventHandler } from "react-native/Libraries/Types/CodegenTypes" // For React Native 0.80, use this: // import { codegenNativeComponent } from "react-native" // For React Native 0.78, use this: import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNativeComponent" -export interface NativeProps extends ViewProps { - // Add Passthrough specific props here if needed -} +export interface NativeProps extends ViewProps {} -export default codegenNativeComponent("IRPassthrough") \ No newline at end of file +export default codegenNativeComponent("IRPassthrough") From a66f7c2eff83ff30706b61bba2dd591429f1f030 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Tue, 23 Sep 2025 13:17:40 -0400 Subject: [PATCH 08/23] lint fix --- app/app.tsx | 12 +++--- app/screens/HelpScreen.tsx | 2 +- app/utils/useMenuItem.tsx | 1 - bin/generate_windows_native_files.js | 56 +++++++++++++++------------- 4 files changed, 38 insertions(+), 33 deletions(-) diff --git a/app/app.tsx b/app/app.tsx index b79fd4f..c44f4b0 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -73,12 +73,12 @@ function App(): React.JSX.Element { }, ...(__DEV__ ? [ - { - label: "Toggle Dev Menu", - shortcut: "cmd+shift+d", - action: () => NativeModules.DevMenu.show(), - }, - ] + { + label: "Toggle Dev Menu", + shortcut: "cmd+shift+d", + action: () => NativeModules.DevMenu.show(), + }, + ] : []), ], Window: [ diff --git a/app/screens/HelpScreen.tsx b/app/screens/HelpScreen.tsx index 219a5c2..328db3a 100644 --- a/app/screens/HelpScreen.tsx +++ b/app/screens/HelpScreen.tsx @@ -140,7 +140,7 @@ const $keystrokeKey = themed(({ spacing, colors }) => ({ alignItems: "center", })) -const $keystroke = themed(({ colors, spacing }) => ({ +const $keystroke = themed(({ colors }) => ({ fontSize: 16, fontWeight: "bold", color: colors.mainText, diff --git a/app/utils/useMenuItem.tsx b/app/utils/useMenuItem.tsx index 7fc2536..dc094ec 100644 --- a/app/utils/useMenuItem.tsx +++ b/app/utils/useMenuItem.tsx @@ -57,7 +57,6 @@ import NativeIRMenuItemManager, { type MenuListEntry, SEPARATOR, } from "../native/IRMenuItemManager/NativeIRMenuItemManager" -import { Alert, Platform } from "react-native" // Only thing to configure here is the path separator. const PATH_SEPARATOR = " > " diff --git a/bin/generate_windows_native_files.js b/bin/generate_windows_native_files.js index 582dc34..4734388 100644 --- a/bin/generate_windows_native_files.js +++ b/bin/generate_windows_native_files.js @@ -10,22 +10,22 @@ const path = require("path") // Color constants for output (matching Ruby script exactly) const colors = process.env.NO_COLOR ? { - R: "", - RB: "", - G: "", - GB: "", - BB: "", - Y: "", - YB: "", - D: "", - DD: "", - DB: "", - DDB: "", - S: "", - X: "\x1b[0m", - } + R: "", + RB: "", + G: "", + GB: "", + BB: "", + Y: "", + YB: "", + D: "", + DD: "", + DB: "", + DDB: "", + S: "", + X: "\x1b[0m", + } : process.env.PREFERS_CONTRAST === "more" - ? { + ? { R: "\x1b[91m", RB: "\x1b[91m", G: "\x1b[92m", @@ -40,7 +40,7 @@ const colors = process.env.NO_COLOR S: "\x1b[9m", X: "\x1b[0m", } - : { + : { R: "\x1b[31m", RB: "\x1b[31;1m", G: "\x1b[32m", @@ -199,7 +199,8 @@ function findWindowsNativeFiles(appPath, projectRoot) { ) } else { console.log( - `${colors.YB} ⚠ Warning ${colors.X}${colors.D}${name} missing ${module.files.h ? ".cpp" : ".h" + `${colors.YB} ⚠ Warning ${colors.X}${colors.D}${name} missing ${ + module.files.h ? ".cpp" : ".h" } file${colors.X}`, ) } @@ -227,14 +228,16 @@ function generateConsolidatedFiles(modules, windowsDir, projectRoot) { fs.writeFileSync(headerPath, headerContent) const relativePath = path.relative(projectRoot, headerPath) console.log( - `${colors.BB} ➕ Generated ${colors.X} ${colors.BB}IRNativeModules.g.h${colors.X} ${colors.DD + `${colors.BB} ➕ Generated ${colors.X} ${colors.BB}IRNativeModules.g.h${colors.X} ${ + colors.DD }${path.dirname(relativePath)}${colors.X}`, ) changesMade = true } else { const relativePath = path.relative(projectRoot, headerPath) console.log( - `${colors.DB} ✔️ Exists ${colors.X}${colors.DB}IRNativeModules.g.h${colors.X} ${colors.DD + `${colors.DB} ✔️ Exists ${colors.X}${colors.DB}IRNativeModules.g.h${colors.X} ${ + colors.DD }${path.dirname(relativePath)}${colors.X}`, ) } @@ -247,14 +250,16 @@ function generateConsolidatedFiles(modules, windowsDir, projectRoot) { fs.writeFileSync(cppPath, cppContent) const relativePath = path.relative(projectRoot, cppPath) console.log( - `${colors.BB} ➕ Generated ${colors.X} ${colors.BB}IRNativeModules.g.cpp${colors.X} ${colors.DD + `${colors.BB} ➕ Generated ${colors.X} ${colors.BB}IRNativeModules.g.cpp${colors.X} ${ + colors.DD }${path.dirname(relativePath)}${colors.X}`, ) changesMade = true } else { const relativePath = path.relative(projectRoot, cppPath) console.log( - `${colors.DB} ✔️ Exists ${colors.X}${colors.DB}IRNativeModules.g.cpp${colors.X} ${colors.DD + `${colors.DB} ✔️ Exists ${colors.X}${colors.DB}IRNativeModules.g.cpp${colors.X} ${ + colors.DD }${path.dirname(relativePath)}${colors.X}`, ) } @@ -302,10 +307,11 @@ function generateHeaderTemplate(modules, windowsDir) { // // TurboModules (${turboModules.length}) will be auto-registered by AddAttributedModules() // Fabric Components (${fabricComponents.length}) require manual registration calls -${unknownModules.length > 0 - ? `// Unknown modules (${unknownModules.length}) - please check their implementation` - : "" - } +${ + unknownModules.length > 0 + ? `// Unknown modules (${unknownModules.length}) - please check their implementation` + : "" +} ${allIncludes} From d678ee7c73a5b3aa2dbdae0abafcc7b09ae1259e Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Wed, 24 Sep 2025 09:15:14 -0400 Subject: [PATCH 09/23] Simplify module --- .../IRPassthroughView.windows.cpp | 65 +++++++------------ .../IRPassthroughView.windows.h | 1 - 2 files changed, 23 insertions(+), 43 deletions(-) diff --git a/app/native/IRPassthroughView/IRPassthroughView.windows.cpp b/app/native/IRPassthroughView/IRPassthroughView.windows.cpp index c53fdff..e2cdffa 100644 --- a/app/native/IRPassthroughView/IRPassthroughView.windows.cpp +++ b/app/native/IRPassthroughView/IRPassthroughView.windows.cpp @@ -22,25 +22,16 @@ IRPassthroughView::~IRPassthroughView() { void RegisterIRPassthroughNativeComponent( winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder) noexcept { - reactotronCodegen::RegisterIRPassthroughNativeComponent( - packageBuilder, - [](const winrt::Microsoft::ReactNative::Composition::IReactCompositionViewComponentBuilder &builder) { - // Disable default border handling to prevent visual clipping issues - builder.SetViewFeatures( - winrt::Microsoft::ReactNative::Composition::ComponentViewFeatures::Default & - ~winrt::Microsoft::ReactNative::Composition::ComponentViewFeatures::NativeBorder); - }); + reactotronCodegen::RegisterIRPassthroughNativeComponent(packageBuilder); } winrt::Microsoft::UI::Composition::Visual IRPassthroughView::CreateVisual( const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { + // React Native Windows requires a visual for the component tree auto compositor = view.as().Compositor(); - - m_visual = compositor.CreateSpriteVisual(); + auto visual = compositor.CreateSpriteVisual(); m_view = view; - - // Create visual that can be styled from React Native - return m_visual; + return visual; } void IRPassthroughView::Initialize(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { @@ -51,19 +42,7 @@ void IRPassthroughView::Initialize(const winrt::Microsoft::ReactNative::Componen [wkThis = get_weak()]( const winrt::IInspectable & /*sender*/, const winrt::Microsoft::ReactNative::LayoutMetricsChangedArgs &args) { if (auto strongThis = wkThis.get()) { - auto visual = strongThis->m_visual; - - // Manually position visual since we disabled default border handling - visual.Size( - {args.NewLayoutMetrics().Frame.Width * args.NewLayoutMetrics().PointScaleFactor, - args.NewLayoutMetrics().Frame.Height * args.NewLayoutMetrics().PointScaleFactor}); - visual.Offset({ - args.NewLayoutMetrics().Frame.X * args.NewLayoutMetrics().PointScaleFactor, - args.NewLayoutMetrics().Frame.Y * args.NewLayoutMetrics().PointScaleFactor, - 0.0f, - }); - - // Update passthrough regions for title bar interaction + // Update passthrough regions whenever component layout changes UpdateAllPassthroughRegions(); } }); @@ -72,41 +51,43 @@ void IRPassthroughView::Initialize(const winrt::Microsoft::ReactNative::Componen winrt::Windows::Graphics::RectInt32 IRPassthroughView::GetPassthroughRect() const noexcept { - if (!m_visual || !m_view) { + if (!m_view) { return { 0, 0, 0, 0 }; } - auto size = m_visual.Size(); - auto offset = m_visual.Offset(); + // Get component position and size from React Native layout system + auto layoutMetrics = m_view.LayoutMetrics(); + auto frame = layoutMetrics.Frame; + auto scale = layoutMetrics.PointScaleFactor; - // Get rectangle coordinates for passthrough regions + // Convert to screen coordinates for Windows passthrough regions return { - static_cast(offset.x), - static_cast(offset.y), - static_cast(size.x), - static_cast(size.y) + static_cast(frame.X * scale), + static_cast(frame.Y * scale), + static_cast(frame.Width * scale), + static_cast(frame.Height * scale) }; } void IRPassthroughView::UpdateAllPassthroughRegions() noexcept { try { - // Find the main window for our process using more reliable enumeration + // Find the application window using process enumeration HWND hwnd = nullptr; EnumWindows([](HWND h, LPARAM p) -> BOOL { DWORD pid = 0; GetWindowThreadProcessId(h, &pid); if (pid == GetCurrentProcessId() && IsWindowVisible(h) && !GetParent(h)) { *reinterpret_cast(p) = h; - return FALSE; // Stop enumeration + return FALSE; } - return TRUE; // Continue enumeration + return TRUE; }, reinterpret_cast(&hwnd)); if (!hwnd) { - return; // No main window found + return; } - // Convert HWND to AppWindow + // Get Windows App SDK window components auto windowId = winrt::Microsoft::UI::GetWindowIdFromWindow(hwnd); auto appWindow = winrt::Microsoft::UI::Windowing::AppWindow::GetFromWindowId(windowId); if (!appWindow) { @@ -118,11 +99,11 @@ void IRPassthroughView::UpdateAllPassthroughRegions() noexcept { return; } - // Collect rectangles from all PassthroughView instances + // Collect all PassthroughView rectangles std::vector passthroughRects; for (auto* instance : s_instances) { - if (instance && instance->m_visual && instance->m_view) { + if (instance && instance->m_view) { try { auto rect = instance->GetPassthroughRect(); if (rect.Width > 0 && rect.Height > 0) { @@ -136,7 +117,7 @@ void IRPassthroughView::UpdateAllPassthroughRegions() noexcept { } } - // Configure passthrough regions for interactive elements + // Apply passthrough regions to Windows title bar nonClientInputSrc.SetRegionRects( winrt::Microsoft::UI::Input::NonClientRegionKind::Passthrough, passthroughRects diff --git a/app/native/IRPassthroughView/IRPassthroughView.windows.h b/app/native/IRPassthroughView/IRPassthroughView.windows.h index 45ff8ab..e51488b 100644 --- a/app/native/IRPassthroughView/IRPassthroughView.windows.h +++ b/app/native/IRPassthroughView/IRPassthroughView.windows.h @@ -26,7 +26,6 @@ namespace winrt::reactotron::implementation private: winrt::Microsoft::ReactNative::ComponentView::LayoutMetricsChanged_revoker m_layoutMetricChangedRevoker; - winrt::Microsoft::UI::Composition::SpriteVisual m_visual{nullptr}; winrt::Microsoft::ReactNative::ComponentView m_view{nullptr}; static std::vector s_instances; From d6a01be2a4acdc106be0fbe12a6398261333b939 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Wed, 24 Sep 2025 09:59:05 -0400 Subject: [PATCH 10/23] Fix component registration --- .gitignore | 5 ++++- app/native/IRPassthroughView/IRPassthroughView.windows.cpp | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 445e421..7c11bae 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,7 @@ macos/.xcode.env.local msbuild.binlog -.claude/* \ No newline at end of file +.claude/* + +# Windows reserved device names +nul \ No newline at end of file diff --git a/app/native/IRPassthroughView/IRPassthroughView.windows.cpp b/app/native/IRPassthroughView/IRPassthroughView.windows.cpp index e2cdffa..e844c7c 100644 --- a/app/native/IRPassthroughView/IRPassthroughView.windows.cpp +++ b/app/native/IRPassthroughView/IRPassthroughView.windows.cpp @@ -22,7 +22,7 @@ IRPassthroughView::~IRPassthroughView() { void RegisterIRPassthroughNativeComponent( winrt::Microsoft::ReactNative::IReactPackageBuilder const &packageBuilder) noexcept { - reactotronCodegen::RegisterIRPassthroughNativeComponent(packageBuilder); + reactotronCodegen::RegisterIRPassthroughNativeComponent(packageBuilder, nullptr); } winrt::Microsoft::UI::Composition::Visual IRPassthroughView::CreateVisual( From 540d1d8c7b4bad241d6d646278fc7bf65e21e2a0 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Wed, 24 Sep 2025 11:26:00 -0400 Subject: [PATCH 11/23] Fix memory leak issue that's caused by mounting and unmounting passthroughviews --- .../IRPassthroughView.windows.cpp | 100 ++++++++++++------ .../IRPassthroughView.windows.h | 5 + 2 files changed, 71 insertions(+), 34 deletions(-) diff --git a/app/native/IRPassthroughView/IRPassthroughView.windows.cpp b/app/native/IRPassthroughView/IRPassthroughView.windows.cpp index e844c7c..b3e29fd 100644 --- a/app/native/IRPassthroughView/IRPassthroughView.windows.cpp +++ b/app/native/IRPassthroughView/IRPassthroughView.windows.cpp @@ -13,9 +13,13 @@ IRPassthroughView::IRPassthroughView() { } IRPassthroughView::~IRPassthroughView() { + // WARNING: This destructor is rarely called in React Native Windows Fabric due to component lifecycle issues. + // The proper cleanup happens in UnmountChildComponentView() instead. + // Keep this as a safety fallback in case the lifecycle method isn't called. auto it = std::find(s_instances.begin(), s_instances.end(), this); if (it != s_instances.end()) { s_instances.erase(it); + DebugLog("Destructor fallback cleanup - this should rarely happen"); UpdateAllPassthroughRegions(); } } @@ -37,17 +41,32 @@ winrt::Microsoft::UI::Composition::Visual IRPassthroughView::CreateVisual( void IRPassthroughView::Initialize(const winrt::Microsoft::ReactNative::ComponentView &view) noexcept { m_view = view; + // Subscribe to layout changes to keep Windows passthrough regions in sync with React Native layout m_layoutMetricChangedRevoker = view.LayoutMetricsChanged( winrt::auto_revoke, [wkThis = get_weak()]( const winrt::IInspectable & /*sender*/, const winrt::Microsoft::ReactNative::LayoutMetricsChangedArgs &args) { if (auto strongThis = wkThis.get()) { - // Update passthrough regions whenever component layout changes UpdateAllPassthroughRegions(); } }); } +void IRPassthroughView::UnmountChildComponentView(const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::UnmountChildComponentViewArgs &args) noexcept { + // CRITICAL: This is the proper React Native Windows Fabric lifecycle method for component cleanup. + // Unlike C++ destructors, this method is reliably called when React Native unmounts components. + if (view == m_view) { + // Remove this instance from the global instances list and update Windows passthrough regions + auto it = std::find(s_instances.begin(), s_instances.end(), this); + if (it != s_instances.end()) { + s_instances.erase(it); + DebugLog("Component unmounted - cleaned up passthrough region"); + UpdateAllPassthroughRegions(); + } + } +} + winrt::Windows::Graphics::RectInt32 IRPassthroughView::GetPassthroughRect() const noexcept { @@ -55,23 +74,28 @@ winrt::Windows::Graphics::RectInt32 IRPassthroughView::GetPassthroughRect() cons return { 0, 0, 0, 0 }; } - // Get component position and size from React Native layout system - auto layoutMetrics = m_view.LayoutMetrics(); - auto frame = layoutMetrics.Frame; - auto scale = layoutMetrics.PointScaleFactor; - - // Convert to screen coordinates for Windows passthrough regions - return { - static_cast(frame.X * scale), - static_cast(frame.Y * scale), - static_cast(frame.Width * scale), - static_cast(frame.Height * scale) - }; + try { + // Get component position and size from React Native layout system + auto layoutMetrics = m_view.LayoutMetrics(); + auto frame = layoutMetrics.Frame; + auto scale = layoutMetrics.PointScaleFactor; + + // Convert React Native coordinates to Windows screen coordinates for passthrough regions + return { + static_cast(frame.X * scale), + static_cast(frame.Y * scale), + static_cast(frame.Width * scale), + static_cast(frame.Height * scale) + }; + } catch (...) { + DebugLog("Exception getting passthrough rect - component may be unmounted"); + return { 0, 0, 0, 0 }; + } } void IRPassthroughView::UpdateAllPassthroughRegions() noexcept { try { - // Find the application window using process enumeration + // Find the main application window by enumerating all windows for this process HWND hwnd = nullptr; EnumWindows([](HWND h, LPARAM p) -> BOOL { DWORD pid = 0; @@ -84,50 +108,58 @@ void IRPassthroughView::UpdateAllPassthroughRegions() noexcept { }, reinterpret_cast(&hwnd)); if (!hwnd) { + DebugLog("Application window not found"); return; } - // Get Windows App SDK window components + // Get Windows App SDK components needed for passthrough region management auto windowId = winrt::Microsoft::UI::GetWindowIdFromWindow(hwnd); auto appWindow = winrt::Microsoft::UI::Windowing::AppWindow::GetFromWindowId(windowId); if (!appWindow) { + DebugLog("Failed to get AppWindow from window handle"); return; } auto nonClientInputSrc = winrt::Microsoft::UI::Input::InputNonClientPointerSource::GetForWindowId(appWindow.Id()); if (!nonClientInputSrc) { + DebugLog("Failed to get InputNonClientPointerSource"); return; } - // Collect all PassthroughView rectangles - std::vector passthroughRects; + // CRITICAL: Clear only passthrough regions, not all regions (which would break title bar dragging) + // This prevents accumulation of stale regions from unmounted components + nonClientInputSrc.ClearRegionRects(winrt::Microsoft::UI::Input::NonClientRegionKind::Passthrough); + // Collect rectangles from all currently mounted PassthroughView instances + std::vector passthroughRects; for (auto* instance : s_instances) { if (instance && instance->m_view) { - try { - auto rect = instance->GetPassthroughRect(); - if (rect.Width > 0 && rect.Height > 0) { - passthroughRects.push_back(rect); - } - } catch (...) { - #ifdef _DEBUG - OutputDebugStringA("[IRPassthroughView] Exception accessing instance during rect collection\n"); - #endif + auto rect = instance->GetPassthroughRect(); + if (rect.Width > 0 && rect.Height > 0) { + passthroughRects.push_back(rect); } } } - // Apply passthrough regions to Windows title bar - nonClientInputSrc.SetRegionRects( - winrt::Microsoft::UI::Input::NonClientRegionKind::Passthrough, - passthroughRects - ); + // Apply the complete new set of passthrough regions to Windows + // SetRegionRects replaces ALL existing passthrough regions with this new set + if (!passthroughRects.empty()) { + nonClientInputSrc.SetRegionRects( + winrt::Microsoft::UI::Input::NonClientRegionKind::Passthrough, + passthroughRects + ); + } } catch (...) { - #ifdef _DEBUG - OutputDebugStringA("[IRPassthroughView] Exception in UpdateAllPassthroughRegions\n"); - #endif + DebugLog("Exception in UpdateAllPassthroughRegions"); } } +void IRPassthroughView::DebugLog(const std::string& message) noexcept { +#ifdef _DEBUG + std::string fullMessage = "[IRPassthroughView] " + message + "\n"; + OutputDebugStringA(fullMessage.c_str()); +#endif +} + } // namespace winrt::reactotron::implementation \ No newline at end of file diff --git a/app/native/IRPassthroughView/IRPassthroughView.windows.h b/app/native/IRPassthroughView/IRPassthroughView.windows.h index e51488b..099999e 100644 --- a/app/native/IRPassthroughView/IRPassthroughView.windows.h +++ b/app/native/IRPassthroughView/IRPassthroughView.windows.h @@ -22,6 +22,8 @@ namespace winrt::reactotron::implementation winrt::Microsoft::UI::Composition::Visual CreateVisual( const winrt::Microsoft::ReactNative::ComponentView &view) noexcept override; void Initialize(const winrt::Microsoft::ReactNative::ComponentView & /*view*/) noexcept override; + void UnmountChildComponentView(const winrt::Microsoft::ReactNative::ComponentView &view, + const winrt::Microsoft::ReactNative::UnmountChildComponentViewArgs &args) noexcept override; private: @@ -32,6 +34,9 @@ namespace winrt::reactotron::implementation static void UpdateAllPassthroughRegions() noexcept; winrt::Windows::Graphics::RectInt32 GetPassthroughRect() const noexcept; + + // Debug logging helper + static void DebugLog(const std::string& message) noexcept; }; } // namespace winrt::reactotron::implementation \ No newline at end of file From d63a9e0fc01e85889af215555b44e967efbf3b95 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Thu, 25 Sep 2025 12:01:23 -0400 Subject: [PATCH 12/23] generate uuid --- app/utils/random/IRRandom.windows.cpp | 28 +++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/app/utils/random/IRRandom.windows.cpp b/app/utils/random/IRRandom.windows.cpp index 88f1a74..ddb7090 100644 --- a/app/utils/random/IRRandom.windows.cpp +++ b/app/utils/random/IRRandom.windows.cpp @@ -7,6 +7,9 @@ #include "pch.h" #include "IRRandom.windows.h" +#include +#include +#include namespace winrt::reactotron::implementation { @@ -17,7 +20,28 @@ namespace winrt::reactotron::implementation std::string IRRandom::getUUID() noexcept { - // TODO: Generate UUID on Windows using CoCreateGuid or similar - return "00000000-0000-0000-0000-000000000000"; + GUID guid; + HRESULT result = CoCreateGuid(&guid); + + if (FAILED(result)) + { + return "00000000-0000-0000-0000-000000000000"; + } + + std::ostringstream stream; + stream << std::hex << std::uppercase << std::setfill('0') + << std::setw(8) << guid.Data1 << "-" + << std::setw(4) << guid.Data2 << "-" + << std::setw(4) << guid.Data3 << "-" + << std::setw(2) << static_cast(guid.Data4[0]) + << std::setw(2) << static_cast(guid.Data4[1]) << "-" + << std::setw(2) << static_cast(guid.Data4[2]) + << std::setw(2) << static_cast(guid.Data4[3]) + << std::setw(2) << static_cast(guid.Data4[4]) + << std::setw(2) << static_cast(guid.Data4[5]) + << std::setw(2) << static_cast(guid.Data4[6]) + << std::setw(2) << static_cast(guid.Data4[7]); + + return stream.str(); } } \ No newline at end of file From 8e735a78502267525968f6975767143383ba02d6 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Thu, 25 Sep 2025 16:56:09 -0400 Subject: [PATCH 13/23] Add basic menu --- app/components/Menu/MenuDropdown.tsx | 110 ++++++++++++++++ app/components/Menu/MenuDropdownItem.tsx | 145 ++++++++++++++++++++++ app/components/Menu/MenuOverlay.tsx | 44 +++++++ app/components/Menu/menuSettings.ts | 13 ++ app/components/Menu/types.ts | 13 ++ app/components/Menu/useMenuPositioning.ts | 50 ++++++++ app/components/Menu/useSubmenuState.ts | 37 ++++++ 7 files changed, 412 insertions(+) create mode 100644 app/components/Menu/MenuDropdown.tsx create mode 100644 app/components/Menu/MenuDropdownItem.tsx create mode 100644 app/components/Menu/MenuOverlay.tsx create mode 100644 app/components/Menu/menuSettings.ts create mode 100644 app/components/Menu/types.ts create mode 100644 app/components/Menu/useMenuPositioning.ts create mode 100644 app/components/Menu/useSubmenuState.ts diff --git a/app/components/Menu/MenuDropdown.tsx b/app/components/Menu/MenuDropdown.tsx new file mode 100644 index 0000000..fbc4feb --- /dev/null +++ b/app/components/Menu/MenuDropdown.tsx @@ -0,0 +1,110 @@ +import { View, type ViewStyle, type TextStyle } from "react-native" +import { useRef, useMemo, memo } from "react" +import { themed } from "../../theme/theme" +import { Portal } from "../Portal" +import { MenuDropdownItem } from "./MenuDropdownItem" +import { useSubmenuState } from "./useSubmenuState" +import { menuSettings } from "./menuSettings" +import { type Position } from "./types" +import { MenuItem, SEPARATOR } from "../../utils/useMenuItem" +import { getUUID } from "../../utils/random/getUUID" +import { Separator } from "../Separator" + +type DropdownMenuItem = MenuItem & { + submenu?: (DropdownMenuItem | typeof SEPARATOR)[] +} + +interface MenuDropdownProps { + items: (DropdownMenuItem | typeof SEPARATOR)[] + position: Position + onItemPress: (item: MenuItem) => void + isSubmenu?: boolean +} + +const MenuDropdownComponent = ({ + items, + position, + onItemPress, + isSubmenu, +}: MenuDropdownProps) => { + const portalName = useRef( + `${isSubmenu ? 'submenu' : 'dropdown'}-${getUUID()}` + ).current + const { openSubmenu, submenuPosition, handleItemHover } = useSubmenuState(position) + + const isSeparator = (item: MenuItem | typeof SEPARATOR): item is typeof SEPARATOR => { + return item === SEPARATOR + } + + // Find the submenu item if one is open + const submenuItem = openSubmenu + ? items.find(item => !isSeparator(item) && item.label === openSubmenu) as DropdownMenuItem | undefined + : undefined + + const dropdownContent = useMemo(() => ( + + {items.map((item, index) => { + if (isSeparator(item)) return + + return ( + + ) + })} + + ), [items, isSubmenu, position.x, position.y, onItemPress, handleItemHover]) + + return ( + <> + + {dropdownContent} + + {/* Render submenu */} + {submenuItem?.submenu && ( + + )} + + ) +} + +export const MenuDropdown = memo(MenuDropdownComponent) + +const $dropdown = themed(({ colors, spacing }) => ({ + position: "absolute", + backgroundColor: colors.cardBackground, + borderColor: colors.keyline, + borderWidth: 1, + borderRadius: 4, + minWidth: menuSettings.dropdownMinWidth, + paddingVertical: spacing.xs, + zIndex: menuSettings.zIndex.dropdown, +})) + +const $submenuDropdown = themed(({ colors, spacing }) => ({ + position: "absolute", + backgroundColor: colors.cardBackground, + borderColor: colors.keyline, + borderWidth: 1, + borderRadius: 4, + minWidth: menuSettings.submenuMinWidth, + paddingVertical: spacing.xs, + zIndex: menuSettings.zIndex.submenu, +})) + + diff --git a/app/components/Menu/MenuDropdownItem.tsx b/app/components/Menu/MenuDropdownItem.tsx new file mode 100644 index 0000000..b498f29 --- /dev/null +++ b/app/components/Menu/MenuDropdownItem.tsx @@ -0,0 +1,145 @@ +import { Pressable, Text, View, type ViewStyle, type TextStyle } from "react-native" +import { useState, useRef, memo, useCallback } from "react" +import { themed } from "../../theme/theme" +import { menuSettings } from "./menuSettings" +import type { MenuItem } from "../../utils/useMenuItem" + +interface MenuDropdownItemProps { + item: MenuItem + index: number + onItemPress: (item: MenuItem) => void + onItemHover: (itemLabel: string, index: number, hasSubmenu: boolean) => void +} + +const MenuDropdownItemComponent = ({ + item, + index, + onItemPress, + onItemHover, +}: MenuDropdownItemProps) => { + const [hoveredItem, setHoveredItem] = useState(null) + const hoverTimeoutRef = useRef(null) + const enabled = item.enabled !== false + + const handleHoverIn = useCallback(() => { + // Clear any pending hover clear + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current) + hoverTimeoutRef.current = null + } + setHoveredItem(item.label) + const hasSubmenu = !!item.submenu + onItemHover(item.label, index, hasSubmenu) + }, [item.label, item.submenu, index, onItemHover]) + + const handleHoverOut = useCallback(() => { + // Use a small timeout to prevent flickering between items + hoverTimeoutRef.current = setTimeout(() => { + setHoveredItem((current) => current === item.label ? null : current) + }, 10) + }, [item.label]) + + const handlePress = useCallback(() => { + if (!item.action || !enabled) return + item.action() + onItemPress(item) + }, [item, onItemPress]) + + return ( + [ + $dropdownItem(), + ((pressed || hoveredItem === item.label) && enabled) && $dropdownItemHovered(), + !enabled && $dropdownItemDisabled, + ]} + > + + {item.label} + + + {item.shortcut && ( + + {formatShortcut(item.shortcut)} + + )} + {item.submenu && ( + + ▶ + + )} + + + ) +} + +export const MenuDropdownItem = memo(MenuDropdownItemComponent) + +function formatShortcut(shortcut: string): string { + return shortcut + .replace(/cmd/gi, "Ctrl") + .replace(/shift/gi, "Shift") + .replace(/\+/g, "+") + .split("+") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join("+") +} + +const $dropdownItem = themed(({ spacing }) => ({ + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: 4, + minHeight: menuSettings.itemMinHeight, +})) + +const $dropdownItemHovered = themed(({ colors }) => ({ + backgroundColor: colors.neutralVery, +})) + +const $dropdownItemDisabled = { + opacity: 0.5, +} + +const $dropdownItemText = themed(({ colors, typography }) => ({ + color: colors.mainText, + fontSize: typography.caption, +})) + +const $dropdownItemTextDisabled = themed((theme) => ({ + color: theme.colors.neutral, +})) + +const $shortcut = themed(({ colors, typography, spacing }) => ({ + color: colors.neutral, + fontSize: typography.small, + marginLeft: spacing.md, +})) + +const $submenuArrow = themed(({ colors, typography, spacing }) => ({ + color: colors.neutral, + fontSize: typography.small, + marginLeft: spacing.sm, +})) + +const $rightContent: ViewStyle = { + flexDirection: "row", + alignItems: "center", +} \ No newline at end of file diff --git a/app/components/Menu/MenuOverlay.tsx b/app/components/Menu/MenuOverlay.tsx new file mode 100644 index 0000000..65e6768 --- /dev/null +++ b/app/components/Menu/MenuOverlay.tsx @@ -0,0 +1,44 @@ +import { Pressable, type ViewStyle } from "react-native" +import { Portal } from "../Portal" +import { menuSettings } from "./menuSettings" + +interface MenuOverlayProps { + onPress: () => void + portalName?: string + style?: ViewStyle + excludeArea?: { + top?: number + left?: number + right?: number + bottom?: number + } +} + +export const MenuOverlay = ({ + onPress, + portalName = 'menu-overlay', + style, + excludeArea, +}: MenuOverlayProps) => { + + return ( + + + + ) +} + +interface OverlayStyleArgs { + excludeArea?: { top?: number, left?: number, right?: number, bottom?: number } + style?: ViewStyle +} + +const overlayStyle: (args: OverlayStyleArgs) => ViewStyle = ({ excludeArea, style }: OverlayStyleArgs) => ({ + position: "absolute", + top: excludeArea?.top ?? 0, + left: excludeArea?.left ?? 0, + right: excludeArea?.right ?? 0, + bottom: excludeArea?.bottom ?? 0, + zIndex: menuSettings.zIndex.menuOverlay, + ...style, +}) \ No newline at end of file diff --git a/app/components/Menu/menuSettings.ts b/app/components/Menu/menuSettings.ts new file mode 100644 index 0000000..79a034f --- /dev/null +++ b/app/components/Menu/menuSettings.ts @@ -0,0 +1,13 @@ +export const menuSettings = { + dropdownMinWidth: 200, + submenuMinWidth: 150, + itemMinHeight: 28, + itemHeight: 32, + submenuOffsetX: 200, + submenuOffsetY: -5, + zIndex: { + menuOverlay: 9999, + dropdown: 10000, + submenu: 10001, + } +} as const \ No newline at end of file diff --git a/app/components/Menu/types.ts b/app/components/Menu/types.ts new file mode 100644 index 0000000..b9e0e96 --- /dev/null +++ b/app/components/Menu/types.ts @@ -0,0 +1,13 @@ +export interface Position { + x: number + y: number +} + +export interface MenuItemWithSubmenu { + label: string + shortcut?: string + enabled?: boolean + position?: number + action?: () => void + submenu?: (MenuItemWithSubmenu | 'menu-item-separator')[] +} \ No newline at end of file diff --git a/app/components/Menu/useMenuPositioning.ts b/app/components/Menu/useMenuPositioning.ts new file mode 100644 index 0000000..cac99d6 --- /dev/null +++ b/app/components/Menu/useMenuPositioning.ts @@ -0,0 +1,50 @@ +import { useCallback } from "react" +import { menuSettings } from "./menuSettings" +import type { Position } from "./types" + +export interface PositioningStrategy { + calculateSubmenuPosition: ( + basePosition: Position, + itemIndex: number, + parentWidth?: number + ) => Position + calculateContextMenuPosition?: ( + clickPosition: Position, + menuSize?: { width: number; height: number }, + screenSize?: { width: number; height: number } + ) => Position +} + +const defaultStrategy: PositioningStrategy = { + calculateSubmenuPosition: (basePosition, itemIndex, parentWidth = menuSettings.submenuOffsetX) => ({ + x: basePosition.x + parentWidth, + y: basePosition.y + itemIndex * menuSettings.itemHeight + menuSettings.submenuOffsetY, + }), + + calculateContextMenuPosition: (clickPosition, menuSize, screenSize) => { + // Basic positioning - can be enhanced for screen edge detection + return { + x: clickPosition.x, + y: clickPosition.y, + } + }, +} + +export const useMenuPositioning = (strategy: PositioningStrategy = defaultStrategy) => { + const calculateSubmenuPosition = useCallback( + (basePosition: Position, itemIndex: number, parentWidth?: number) => + strategy.calculateSubmenuPosition(basePosition, itemIndex, parentWidth), + [strategy] + ) + + const calculateContextMenuPosition = useCallback( + (clickPosition: Position, menuSize?: { width: number; height: number }, screenSize?: { width: number; height: number }) => + strategy.calculateContextMenuPosition?.(clickPosition, menuSize, screenSize) ?? clickPosition, + [strategy] + ) + + return { + calculateSubmenuPosition, + calculateContextMenuPosition, + } +} \ No newline at end of file diff --git a/app/components/Menu/useSubmenuState.ts b/app/components/Menu/useSubmenuState.ts new file mode 100644 index 0000000..1c105ca --- /dev/null +++ b/app/components/Menu/useSubmenuState.ts @@ -0,0 +1,37 @@ +import { useState, useCallback } from "react" +import { menuSettings } from "./menuSettings" +import { type Position } from "./types" + +export const useSubmenuState = (basePosition: Position) => { + const [openSubmenu, setOpenSubmenu] = useState(null) + const [submenuPosition, setSubmenuPosition] = useState({ x: 0, y: 0 }) + + const openSubmenuAt = useCallback((itemLabel: string, index: number) => { + setOpenSubmenu(itemLabel) + setSubmenuPosition({ + x: basePosition.x + menuSettings.submenuOffsetX, + y: basePosition.y + index * menuSettings.itemHeight + menuSettings.submenuOffsetY, + }) + }, [basePosition.x, basePosition.y]) + + const closeSubmenu = useCallback(() => { + setOpenSubmenu(null) + }, []) + + const handleItemHover = useCallback((itemLabel: string, index: number, hasSubmenu: boolean) => { + if (hasSubmenu) { + openSubmenuAt(itemLabel, index) + } else { + if (openSubmenu) { + closeSubmenu() + } + } + }, [openSubmenu, openSubmenuAt, closeSubmenu]) + + return { + openSubmenu, + submenuPosition, + handleItemHover, + closeSubmenu, + } +} \ No newline at end of file From 0f06046b86c7553643764b213cbb5c48fbfa3c05 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Thu, 25 Sep 2025 17:00:31 -0400 Subject: [PATCH 14/23] Add titlebar menu --- app/components/Titlebar/Titlebar.tsx | 6 ++ app/components/Titlebar/TitlebarMenu.tsx | 81 ++++++++++++++++++++ app/components/Titlebar/TitlebarMenuItem.tsx | 60 +++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 app/components/Titlebar/TitlebarMenu.tsx create mode 100644 app/components/Titlebar/TitlebarMenuItem.tsx diff --git a/app/components/Titlebar/Titlebar.tsx b/app/components/Titlebar/Titlebar.tsx index c939596..6c4e029 100644 --- a/app/components/Titlebar/Titlebar.tsx +++ b/app/components/Titlebar/Titlebar.tsx @@ -4,6 +4,7 @@ import { Icon } from "../Icon" import ActionButton from "../ActionButton" import { useSidebar } from "../../state/useSidebar" import { PassthroughView } from "./PassthroughView" +import { TitlebarMenu } from "./TitlebarMenu" export const Titlebar = () => { const theme = useTheme() @@ -13,6 +14,11 @@ export const Titlebar = () => { + {Platform.OS === "windows" && ( + + + + )} ( diff --git a/app/components/Titlebar/TitlebarMenu.tsx b/app/components/Titlebar/TitlebarMenu.tsx new file mode 100644 index 0000000..aff8c1c --- /dev/null +++ b/app/components/Titlebar/TitlebarMenu.tsx @@ -0,0 +1,81 @@ +import { View, ViewStyle } from "react-native" +import { useState, useCallback, useRef } from "react" +import { themed } from "../../theme/theme" +import { TitlebarMenuItem } from "./TitlebarMenuItem" +import { MenuDropdown } from "../Menu/MenuDropdown" +import { MenuOverlay } from "../Menu/MenuOverlay" +import type { Position } from "../Menu/types" +import { PassthroughView } from "./PassthroughView" +import { useMenuItem } from "../../utils/useMenuItem" + +export const TitlebarMenu = () => { + const { menuStructure, menuItems, handleMenuItemPressed } = useMenuItem() + const [openMenu, setOpenMenu] = useState(null) + const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 }) + const menuRefs = useRef>(new Map()) + + const handleMenuClick = useCallback((menuName: string) => { + const menuRef = menuRefs.current.get(menuName) + if (!menuRef) return + menuRef.measureInWindow((x, y, _, height) => { + setDropdownPosition({ x, y: y + height }) + setOpenMenu((prev) => (prev === menuName ? null : menuName)) + }) + }, []) + + const handleMenuHover = useCallback( + (menuName: string) => { + if (openMenu === null || openMenu === menuName) return + handleMenuClick(menuName) + }, + [openMenu, handleMenuClick], + ) + + const handleClose = () => setOpenMenu(null) + + // TODO: Add hotkey handling + + return ( + + {menuStructure.map((menu) => ( + { + if (ref) menuRefs.current.set(menu.title, ref) + }} + > + handleMenuClick(menu.title)} + onHoverIn={() => handleMenuHover(menu.title)} + /> + + ))} + {openMenu && menuItems[openMenu] && ( + <> + {/* Single overlay for all menu interactions */} + + { + handleMenuItemPressed({ menuPath: [openMenu, item.label] }) + handleClose() + }} + /> + + )} + + ) +} + +const $menuBar = themed(() => ({ + flexDirection: "row", + alignItems: "center", + height: "100%", + paddingHorizontal: 4, +})) \ No newline at end of file diff --git a/app/components/Titlebar/TitlebarMenuItem.tsx b/app/components/Titlebar/TitlebarMenuItem.tsx new file mode 100644 index 0000000..2fd03ce --- /dev/null +++ b/app/components/Titlebar/TitlebarMenuItem.tsx @@ -0,0 +1,60 @@ +import { Pressable, Text, type TextStyle, type ViewStyle } from "react-native" +import { useCallback, useState } from "react" +import { themed } from "../../theme/theme" + +interface TitlebarMenuItemProps { + title: string + isOpen?: boolean + onPress: () => void + onHoverIn?: () => void + onHoverOut?: () => void +} + +export const TitlebarMenuItem = ({ title, isOpen, onPress, onHoverIn, onHoverOut }: TitlebarMenuItemProps) => { + const [isHovered, setIsHovered] = useState(false) + + const handleHover = useCallback((isHovered: boolean) => { + setIsHovered(isHovered) + if (isHovered) { + onHoverIn?.() + } else { + onHoverOut?.() + } + }, [onHoverIn, onHoverOut]) + + return ( + { + handleHover(true) + }} + onHoverOut={() => { + handleHover(false) + }} + style={({ pressed }) => [ + $menuItem(), + (pressed || isOpen || isHovered) && $menuItemHovered(), + ]} + > + {title} + + ) +} + +const $menuItem = themed(({ spacing }) => ({ + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: 4, + justifyContent: "center", +})) + +const $menuItemHovered = themed(({ colors }) => ({ + backgroundColor: colors.neutralVery, + opacity: 0.8, +})) + +const $menuItemText = themed(({ colors, typography }) => ({ + color: colors.mainText, + fontSize: typography.caption, + fontWeight: "400", +})) \ No newline at end of file From 3a1ae6696b951851dc7ba2e7eb7ca3967d4c023d Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Thu, 25 Sep 2025 17:00:51 -0400 Subject: [PATCH 15/23] More stable useGlobal for windows --- app/state/useGlobal.windows.ts | 141 +++++++++++++++++---------------- 1 file changed, 73 insertions(+), 68 deletions(-) diff --git a/app/state/useGlobal.windows.ts b/app/state/useGlobal.windows.ts index b13f1ac..edbc94b 100644 --- a/app/state/useGlobal.windows.ts +++ b/app/state/useGlobal.windows.ts @@ -1,87 +1,92 @@ -import { Dispatch, SetStateAction, useCallback, useEffect, useState } from "react" +// globalStore.ts +import { useSyncExternalStore, useCallback } from "react"; +import { unstable_batchedUpdates } from "react-native"; -type UseGlobalOptions = Record +type Id = string; +type Listener = () => void; +type SetValue = T | ((prev: T) => T); -const globals: Record = {} -const components_to_rerender: Record>[]> = {} +const globals = new Map(); +const listeners = new Map>(); -type SetValueFn = (prev: T) => T -type SetValue = T | SetValueFn +// Initialize global value if it doesn't exist, but don't modify during snapshot reads +function initializeGlobal(id: Id, initialValue: T): void { + if (!globals.has(id)) { + globals.set(id, initialValue); + } +} + +function getSnapshot(id: Id): T { + return globals.get(id) as T; +} -/** - * Trying for the simplest possible global state management. - * Use anywhere and it'll share the same state globally, and rerender any component that uses it. - * - * const [value, setValue] = useGlobal("my-state", "initial-value") - * - */ -export function useGlobal( - id: string, - initialValue: T, - options: UseGlobalOptions = {}, -): [T, (value: SetValue) => void] { - // This is a dummy state to rerender any component that uses this global. - const [_v, setRender] = useState([]) +function subscribe(id: Id, cb: Listener): () => void { + let set = listeners.get(id); + if (!set) listeners.set(id, (set = new Set())); + set.add(cb); + return () => { + const s = listeners.get(id); + if (!s) return; + s.delete(cb); + if (s.size === 0) listeners.delete(id); + }; +} + +function notify(id: Id) { + const s = listeners.get(id); + if (!s || s.size === 0) return; + unstable_batchedUpdates(() => { + for (const l of s) l(); + }); +} - // Subscribe & unsubscribe from state changes for this ID. - useEffect(() => { - components_to_rerender[id] ||= [] - components_to_rerender[id].push(setRender) - return () => { - if (!components_to_rerender[id]) return - components_to_rerender[id] = components_to_rerender[id].filter( - (listener) => listener !== setRender, - ) - } - }, [id]) +export function useGlobal(id: Id, initialValue: T): [T, (v: SetValue) => void] { + // Initialize the global value once, outside of the snapshot function + initializeGlobal(id, initialValue); - // We use the withGlobal hook to do the actual work. - const [value] = withGlobal(id, initialValue, options) + const value = useSyncExternalStore( + (cb) => subscribe(id, cb), + () => getSnapshot(id) + ); - // We use a callback to ensure that the setValue function is stable. - const setValue = useCallback(buildSetValue(id), [id]) + // Memoize the setter function to prevent unnecessary re-renders + const set = useCallback((next: SetValue) => { + const current = getSnapshot(id); + const resolved = typeof next === "function" ? (next as (p: T) => T)(current) : next; + globals.set(id, resolved); + notify(id); + }, [id]); - return [value, setValue] + return [value, set]; } -/** - * For global state used outside of a component. Can be used in a component with - * the same id string, using useGlobal. - */ +// Imperative access (outside components) export function withGlobal( - id: string, - initialValue: T, - _: UseGlobalOptions = {}, -): [T, (value: SetValue | null) => void] { - // Initialize this global if it doesn't exist. - if (globals[id] === undefined) globals[id] = initialValue + id: Id, + initialValue: T +): [T, (v: SetValue | null) => void] { + // Initialize the global value + initializeGlobal(id, initialValue); - return [globals[id] as T, buildSetValue(id)] + const setter = (v: SetValue | null) => { + if (v === null) return resetGlobal(id); + const current = getSnapshot(id); + const resolved = typeof v === "function" ? (v as (p: T) => T)(current) : v; + globals.set(id, resolved); + notify(id); + }; + return [getSnapshot(id), setter]; } -function buildSetValue(id: string) { - return (value: SetValue | null) => { - // Call the setter function if it's a function. - if (typeof value === "function") value = (value as SetValueFn)(globals[id] as T) - if (value === null) { - delete globals[id] - } else { - globals[id] = value - } - components_to_rerender[id] ||= [] - components_to_rerender[id].forEach((rerender) => rerender([])) - } +export function resetGlobal(id: Id, rerender = true) { + globals.delete(id); + if (rerender) notify(id); } -/** - * Clear all globals and reset the storage entirely. - * Optionally rerender all components that use useGlobal. - */ -export function clearGlobals(rerender: boolean = true): void { - Object.keys(globals).forEach((key) => delete globals[key]) +export function clearGlobals(rerender = true) { + globals.clear(); if (rerender) { - Object.keys(components_to_rerender).forEach((key) => { - components_to_rerender[key].forEach((rerender) => rerender([])) - }) + // Only notify ids that have listeners; avoids stale maps + for (const id of listeners.keys()) notify(id); } } From fa870f66b1856ea52e7b040b854667e2654dcadc Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Thu, 25 Sep 2025 17:01:21 -0400 Subject: [PATCH 16/23] WIP: Add windows support for useMenuItem hook --- app/utils/useMenuItem.tsx | 167 ++++++++++++++++++++++++++++++-------- 1 file changed, 132 insertions(+), 35 deletions(-) diff --git a/app/utils/useMenuItem.tsx b/app/utils/useMenuItem.tsx index dc094ec..133efbe 100644 --- a/app/utils/useMenuItem.tsx +++ b/app/utils/useMenuItem.tsx @@ -51,6 +51,8 @@ */ import { useEffect, useRef, useCallback, useState } from "react" +import { Platform } from "react-native" +import { useGlobal } from "../state/useGlobal" import NativeIRMenuItemManager, { type MenuItemPressedEvent, type MenuStructure, @@ -68,7 +70,8 @@ export interface MenuItem { shortcut?: string enabled?: boolean position?: number - action: () => void + action?: () => void // Make action optional for items with submenus + submenu?: MenuListEntry[] // Add submenu support } export interface MenuItemConfig { @@ -91,6 +94,21 @@ export function useMenuItem(config?: MenuItemConfig) { const previousConfigRef = useRef(null) const [availableMenus, setAvailableMenus] = useState([]) const [menuStructure, setMenuStructure] = useState([]) + const [windowsMenuItems, setWindowsMenuItems] = useState>({}) + + // Global state for Windows menu persistence + const [globalMenuConfig, setGlobalMenuConfig] = useGlobal( + "windows-menu-config", + null, + ) + const [globalMenuStructure, setGlobalMenuStructure] = useGlobal( + "windows-menu-structure", + [], + ) + const [globalMenuItems, setGlobalMenuItems] = useGlobal>( + "windows-menu-items", + {}, + ) const handleMenuItemPressed = useCallback((event: MenuItemPressedEvent) => { const key = joinPath(event.menuPath) @@ -100,16 +118,46 @@ export function useMenuItem(config?: MenuItemConfig) { const discoverMenus = useCallback(async () => { try { - const menus = NativeIRMenuItemManager.getAvailableMenus() - const structure = NativeIRMenuItemManager.getMenuStructure() - setAvailableMenus(menus) - setMenuStructure(structure) - return menus + if (Platform.OS === "windows") { + // For Windows, use global state + const configToUse = config || globalMenuConfig + if (configToUse?.items) { + const winStructure: MenuStructure = Object.keys(configToUse.items).map((title) => ({ + title, + enabled: true, + path: [title], + items: [], + children: [], + })) + + // Update global state if we have a config + if (config && config !== globalMenuConfig) { + setGlobalMenuConfig(config) + setGlobalMenuStructure(winStructure) + setGlobalMenuItems(config.items as Record) + } + + // Always use global state for consistency + setMenuStructure(globalMenuStructure.length > 0 ? globalMenuStructure : winStructure) + setWindowsMenuItems( + Object.keys(globalMenuItems).length > 0 + ? globalMenuItems + : (configToUse.items as Record), + ) + } + return [] + } else { + const menus = NativeIRMenuItemManager.getAvailableMenus() + const structure = NativeIRMenuItemManager.getMenuStructure() + setAvailableMenus(menus) + setMenuStructure(structure) + return menus + } } catch (error) { console.error("Failed to discover menus:", error) return [] } - }, []) + }, [config, globalMenuConfig, globalMenuStructure, globalMenuItems]) const addEntries = useCallback(async (parentKey: string, entries: MenuListEntry[]) => { const parentPath = parsePathKey(parentKey) @@ -248,49 +296,78 @@ export function useMenuItem(config?: MenuItemConfig) { const updateMenus = async () => { if (!config) return - const previousConfig = previousConfigRef.current - - if (config.remove?.length) { - for (const entry of config.remove) { - await removeMenuItemByName(entry) + if (Platform.OS === "windows") { + // For Windows, update global state and action map + if (config.items) { + // Store actions in actionsRef + for (const [parentKey, entries] of Object.entries(config.items)) { + for (const entry of entries) { + if (!isSeparator(entry)) { + const item = entry as MenuItem + const leafPath = [parentKey, item.label] + if (item.action) { + actionsRef.current.set(joinPath(leafPath), item.action) + } + } + } + } + // Update global state + setGlobalMenuConfig(config) + } + previousConfigRef.current = config + await discoverMenus() + } else { + // Original macOS logic + const previousConfig = previousConfigRef.current + + if (config.remove?.length) { + for (const entry of config.remove) { + await removeMenuItemByName(entry) + } } - } - if (config.items) { - for (const [parentKey, entries] of Object.entries(config.items)) { - const previousEntries = previousConfig?.items?.[parentKey] || [] - const { toRemove, toUpdate } = getItemDifference(previousEntries, entries) + if (config.items) { + for (const [parentKey, entries] of Object.entries(config.items)) { + const previousEntries = previousConfig?.items?.[parentKey] || [] + const { toRemove, toUpdate } = getItemDifference(previousEntries, entries) - if (toRemove.length) await removeMenuItems(parentKey, toRemove) + if (toRemove.length) await removeMenuItems(parentKey, toRemove) - await addEntries(parentKey, entries) + await addEntries(parentKey, entries) - for (const item of toUpdate) { - const leafPath = [...parsePathKey(parentKey), item.label] - actionsRef.current.set(joinPath(leafPath), item.action) - if (item.enabled !== undefined) { - try { - await NativeIRMenuItemManager.setMenuItemEnabledAtPath(leafPath, item.enabled) - } catch (e) { - console.error(`Failed to update ${joinPath(leafPath)}:`, e) + for (const item of toUpdate) { + const leafPath = [...parsePathKey(parentKey), item.label] + actionsRef.current.set(joinPath(leafPath), item.action) + if (item.enabled !== undefined) { + try { + await NativeIRMenuItemManager.setMenuItemEnabledAtPath(leafPath, item.enabled) + } catch (e) { + console.error(`Failed to update ${joinPath(leafPath)}:`, e) + } } } } } - } - previousConfigRef.current = config - await discoverMenus() + previousConfigRef.current = config + await discoverMenus() + } } updateMenus() }, [config, addEntries, removeMenuItems, getItemDifference]) useEffect(() => { - const subscription = NativeIRMenuItemManager.onMenuItemPressed(handleMenuItemPressed) - discoverMenus() - return () => { - subscription.remove() + if (Platform.OS === "windows") { + // For Windows, just discover menus from config + discoverMenus() + } else { + // For macOS, use native menu manager + const subscription = NativeIRMenuItemManager.onMenuItemPressed(handleMenuItemPressed) + discoverMenus() + return () => { + subscription.remove() + } } }, [handleMenuItemPressed, discoverMenus]) @@ -337,13 +414,33 @@ export function useMenuItem(config?: MenuItemConfig) { [addEntries], ) + // For Windows, populate actions from global state if no config provided + useEffect(() => { + if (Platform.OS === "windows" && !config && globalMenuConfig?.items) { + // Restore actions from global config + for (const [parentKey, entries] of Object.entries(globalMenuConfig.items)) { + for (const entry of entries) { + if (!isSeparator(entry)) { + const item = entry as MenuItem + const leafPath = [parentKey, item.label] + if (item.action) { + actionsRef.current.set(joinPath(leafPath), item.action) + } + } + } + } + } + }, [config, globalMenuConfig]) + return { availableMenus, - menuStructure, + menuStructure: Platform.OS === "windows" ? globalMenuStructure : menuStructure, + menuItems: Platform.OS === "windows" ? globalMenuItems : {}, discoverMenus, addMenuItem, removeMenuItemByName, setMenuItemEnabled, getAllMenuPaths, + handleMenuItemPressed, } } From 0745644b8e8900714689da30564ef1ae688d0e12 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Fri, 26 Sep 2025 09:19:51 -0400 Subject: [PATCH 17/23] Rename IRMenuItemManager to IRSystemMenuManager for clarity --- app/app.tsx | 4 +- app/components/Menu/MenuDropdown.tsx | 13 +-- app/components/Menu/MenuDropdownItem.tsx | 2 +- app/components/Menu/types.ts | 14 ++- app/components/Titlebar/TitlebarMenu.tsx | 4 +- .../IRMenuItemManager/IRMenuItemManager.h | 8 -- .../IRSystemMenuManager/IRSystemMenuManager.h | 8 ++ .../IRSystemMenuManager.mm} | 6 +- .../IRSystemMenuManager.windows.cpp} | 8 +- .../IRSystemMenuManager.windows.h} | 12 +- .../NativeIRSystemMenuManager.ts} | 24 ++-- .../{useMenuItem.tsx => useSystemMenu.tsx} | 110 +++++++++--------- windows/reactotron/IRNativeModules.g.h | 2 +- 13 files changed, 107 insertions(+), 108 deletions(-) delete mode 100644 app/native/IRMenuItemManager/IRMenuItemManager.h create mode 100644 app/native/IRSystemMenuManager/IRSystemMenuManager.h rename app/native/{IRMenuItemManager/IRMenuItemManager.mm => IRSystemMenuManager/IRSystemMenuManager.mm} (98%) rename app/native/{IRMenuItemManager/IRMenuItemManager.windows.cpp => IRSystemMenuManager/IRSystemMenuManager.windows.cpp} (78%) rename app/native/{IRMenuItemManager/IRMenuItemManager.windows.h => IRSystemMenuManager/IRSystemMenuManager.windows.h} (57%) rename app/native/{IRMenuItemManager/NativeIRMenuItemManager.ts => IRSystemMenuManager/NativeIRSystemMenuManager.ts} (71%) rename app/utils/{useMenuItem.tsx => useSystemMenu.tsx} (77%) diff --git a/app/app.tsx b/app/app.tsx index 17bdb65..0b5378a 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -9,7 +9,7 @@ import { connectToServer } from "./state/connectToServer" import { useTheme, themed } from "./theme/theme" import { useEffect, useMemo } from "react" import { TimelineScreen } from "./screens/TimelineScreen" -import { useMenuItem } from "./utils/useMenuItem" +import { useSystemMenu } from "./utils/useSystemMenu" import { Titlebar } from "./components/Titlebar/Titlebar" import { Sidebar } from "./components/Sidebar/Sidebar" import { useSidebar } from "./state/useSidebar" @@ -101,7 +101,7 @@ function App(): React.JSX.Element { [toggleSidebar], ) - useMenuItem(menuConfig) + useSystemMenu(menuConfig) setTimeout(() => { fetch("https://www.google.com") diff --git a/app/components/Menu/MenuDropdown.tsx b/app/components/Menu/MenuDropdown.tsx index fbc4feb..19e998a 100644 --- a/app/components/Menu/MenuDropdown.tsx +++ b/app/components/Menu/MenuDropdown.tsx @@ -5,17 +5,12 @@ import { Portal } from "../Portal" import { MenuDropdownItem } from "./MenuDropdownItem" import { useSubmenuState } from "./useSubmenuState" import { menuSettings } from "./menuSettings" -import { type Position } from "./types" -import { MenuItem, SEPARATOR } from "../../utils/useMenuItem" +import { type Position, type DropdownMenuItem, type MenuItem, MENU_SEPARATOR } from "./types" import { getUUID } from "../../utils/random/getUUID" import { Separator } from "../Separator" -type DropdownMenuItem = MenuItem & { - submenu?: (DropdownMenuItem | typeof SEPARATOR)[] -} - interface MenuDropdownProps { - items: (DropdownMenuItem | typeof SEPARATOR)[] + items: (DropdownMenuItem | typeof MENU_SEPARATOR)[] position: Position onItemPress: (item: MenuItem) => void isSubmenu?: boolean @@ -32,8 +27,8 @@ const MenuDropdownComponent = ({ ).current const { openSubmenu, submenuPosition, handleItemHover } = useSubmenuState(position) - const isSeparator = (item: MenuItem | typeof SEPARATOR): item is typeof SEPARATOR => { - return item === SEPARATOR + const isSeparator = (item: MenuItem | typeof MENU_SEPARATOR): item is typeof MENU_SEPARATOR => { + return item === MENU_SEPARATOR } // Find the submenu item if one is open diff --git a/app/components/Menu/MenuDropdownItem.tsx b/app/components/Menu/MenuDropdownItem.tsx index b498f29..8628f1f 100644 --- a/app/components/Menu/MenuDropdownItem.tsx +++ b/app/components/Menu/MenuDropdownItem.tsx @@ -2,7 +2,7 @@ import { Pressable, Text, View, type ViewStyle, type TextStyle } from "react-nat import { useState, useRef, memo, useCallback } from "react" import { themed } from "../../theme/theme" import { menuSettings } from "./menuSettings" -import type { MenuItem } from "../../utils/useMenuItem" +import type { MenuItem } from "./types" interface MenuDropdownItemProps { item: MenuItem diff --git a/app/components/Menu/types.ts b/app/components/Menu/types.ts index b9e0e96..486ef78 100644 --- a/app/components/Menu/types.ts +++ b/app/components/Menu/types.ts @@ -3,11 +3,17 @@ export interface Position { y: number } -export interface MenuItemWithSubmenu { +// Generic menu item interface for UI components +export interface MenuItem { label: string shortcut?: string enabled?: boolean - position?: number action?: () => void - submenu?: (MenuItemWithSubmenu | 'menu-item-separator')[] -} \ No newline at end of file + submenu?: (MenuItem | typeof MENU_SEPARATOR)[] +} + +// Type alias for dropdown menu items (same as MenuItem) +export type DropdownMenuItem = MenuItem + +// Menu separator constant +export const MENU_SEPARATOR = 'menu-item-separator' as const \ No newline at end of file diff --git a/app/components/Titlebar/TitlebarMenu.tsx b/app/components/Titlebar/TitlebarMenu.tsx index aff8c1c..ee446e3 100644 --- a/app/components/Titlebar/TitlebarMenu.tsx +++ b/app/components/Titlebar/TitlebarMenu.tsx @@ -6,10 +6,10 @@ import { MenuDropdown } from "../Menu/MenuDropdown" import { MenuOverlay } from "../Menu/MenuOverlay" import type { Position } from "../Menu/types" import { PassthroughView } from "./PassthroughView" -import { useMenuItem } from "../../utils/useMenuItem" +import { useSystemMenu } from "../../utils/useSystemMenu" export const TitlebarMenu = () => { - const { menuStructure, menuItems, handleMenuItemPressed } = useMenuItem() + const { menuStructure, menuItems, handleMenuItemPressed } = useSystemMenu() const [openMenu, setOpenMenu] = useState(null) const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 }) const menuRefs = useRef>(new Map()) diff --git a/app/native/IRMenuItemManager/IRMenuItemManager.h b/app/native/IRMenuItemManager/IRMenuItemManager.h deleted file mode 100644 index c042f99..0000000 --- a/app/native/IRMenuItemManager/IRMenuItemManager.h +++ /dev/null @@ -1,8 +0,0 @@ -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface IRMenuItemManager : NativeIRMenuItemManagerSpecBase -@end - -NS_ASSUME_NONNULL_END diff --git a/app/native/IRSystemMenuManager/IRSystemMenuManager.h b/app/native/IRSystemMenuManager/IRSystemMenuManager.h new file mode 100644 index 0000000..6c379d1 --- /dev/null +++ b/app/native/IRSystemMenuManager/IRSystemMenuManager.h @@ -0,0 +1,8 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface IRSystemMenuManager : NativeIRSystemMenuManagerSpecBase +@end + +NS_ASSUME_NONNULL_END diff --git a/app/native/IRMenuItemManager/IRMenuItemManager.mm b/app/native/IRSystemMenuManager/IRSystemMenuManager.mm similarity index 98% rename from app/native/IRMenuItemManager/IRMenuItemManager.mm rename to app/native/IRSystemMenuManager/IRSystemMenuManager.mm index 82df0bd..e2610e6 100644 --- a/app/native/IRMenuItemManager/IRMenuItemManager.mm +++ b/app/native/IRSystemMenuManager/IRSystemMenuManager.mm @@ -1,16 +1,16 @@ -#import "IRMenuItemManager.h" +#import "IRSystemMenuManager.h" #import #import static NSString * const separatorString = @"menu-item-separator"; -@implementation IRMenuItemManager { +@implementation IRSystemMenuManager { } RCT_EXPORT_MODULE() - (std::shared_ptr)getTurboModule:(const facebook::react::ObjCTurboModule::InitParams &)params { - return std::make_shared(params); + return std::make_shared(params); } #pragma mark - API diff --git a/app/native/IRMenuItemManager/IRMenuItemManager.windows.cpp b/app/native/IRSystemMenuManager/IRSystemMenuManager.windows.cpp similarity index 78% rename from app/native/IRMenuItemManager/IRMenuItemManager.windows.cpp rename to app/native/IRSystemMenuManager/IRSystemMenuManager.windows.cpp index 2df05f9..85220ee 100644 --- a/app/native/IRMenuItemManager/IRMenuItemManager.windows.cpp +++ b/app/native/IRSystemMenuManager/IRSystemMenuManager.windows.cpp @@ -1,18 +1,18 @@ // -// IRMenuItemManager.cpp +// IRSystemMenuManager.cpp // Reactotron-Windows // // Windows TurboModule implementation of menu item management // #include "pch.h" -#include "IRMenuItemManager.windows.h" +#include "IRSystemMenuManager.windows.h" -using winrt::reactotron::implementation::IRMenuItemManager; +using winrt::reactotron::implementation::IRSystemMenuManager; namespace winrt::reactotron::implementation { - void IRMenuItemManager::createMenu(std::string menuName, + void IRSystemMenuManager::createMenu(std::string menuName, ::React::ReactPromise &&result) noexcept { // THE PROBLEM: onMenuItemPressed is nullptr/undefined at runtime diff --git a/app/native/IRMenuItemManager/IRMenuItemManager.windows.h b/app/native/IRSystemMenuManager/IRSystemMenuManager.windows.h similarity index 57% rename from app/native/IRMenuItemManager/IRMenuItemManager.windows.h rename to app/native/IRSystemMenuManager/IRSystemMenuManager.windows.h index e15daf9..88018f3 100644 --- a/app/native/IRMenuItemManager/IRMenuItemManager.windows.h +++ b/app/native/IRSystemMenuManager/IRSystemMenuManager.windows.h @@ -4,17 +4,17 @@ #include // Generated (DataTypes before Spec) -#include "..\..\..\windows\reactotron\codegen\NativeIRMenuItemManagerDataTypes.g.h" -#include "..\..\..\windows\reactotron\codegen\NativeIRMenuItemManagerSpec.g.h" +#include "..\..\..\windows\reactotron\codegen\NativeIRSystemMenuManagerDataTypes.g.h" +#include "..\..\..\windows\reactotron\codegen\NativeIRSystemMenuManagerSpec.g.h" namespace winrt::reactotron::implementation { - REACT_TURBO_MODULE(IRMenuItemManager) - struct IRMenuItemManager : reactotronCodegen::IRMenuItemManagerSpec + REACT_TURBO_MODULE(IRSystemMenuManager) + struct IRSystemMenuManager : reactotronCodegen::IRSystemMenuManagerSpec { // Only the essential types needed for the event - using PressEvent = reactotronCodegen::IRMenuItemManagerSpec_MenuItemPressedEvent; - using CreateRet = reactotronCodegen::IRMenuItemManagerSpec_createMenu_returnType; + using PressEvent = reactotronCodegen::IRSystemMenuManagerSpec_MenuItemPressedEvent; + using CreateRet = reactotronCodegen::IRSystemMenuManagerSpec_createMenu_returnType; // One simple method to test event emission REACT_METHOD(createMenu) diff --git a/app/native/IRMenuItemManager/NativeIRMenuItemManager.ts b/app/native/IRSystemMenuManager/NativeIRSystemMenuManager.ts similarity index 71% rename from app/native/IRMenuItemManager/NativeIRMenuItemManager.ts rename to app/native/IRSystemMenuManager/NativeIRSystemMenuManager.ts index fa17dfe..af59d58 100644 --- a/app/native/IRMenuItemManager/NativeIRMenuItemManager.ts +++ b/app/native/IRSystemMenuManager/NativeIRSystemMenuManager.ts @@ -6,41 +6,41 @@ import { TurboModuleRegistry } from "react-native" export const SEPARATOR = "menu-item-separator" as const // Path shape: ["View", "Zen Mode"] -export interface MenuItemPressedEvent { +export interface SystemMenuItemPressedEvent { menuPath: string[] } // Native -> JS: Tree node describing a menu item returned by getMenuStructure() -export interface MenuNode { +export interface SystemMenuNode { title: string enabled: boolean path: string[] // TODO: This creates an infinite loop when building for windows - // children?: MenuNode[] + // children?: SystemMenuNode[] children?: any } // Native -> JS: Top-level entry from getMenuStructure() -export interface MenuEntry { +export interface SystemMenuEntry { title: string - items: MenuNode[] + items: SystemMenuNode[] } -export type MenuStructure = MenuEntry[] +export type SystemMenuStructure = SystemMenuEntry[] -// JS -> Native: For building menu -export interface MenuItem { +// JS -> Native: For building menu (legacy - use SystemMenuItem instead) +export interface SystemNativeMenuItem { label: string shortcut?: string enabled?: boolean action: () => void } -export type MenuListEntry = MenuItem | typeof SEPARATOR +export type SystemMenuListEntry = SystemNativeMenuItem | typeof SEPARATOR export interface Spec extends TurboModule { getAvailableMenus(): string[] - getMenuStructure(): MenuStructure + getMenuStructure(): SystemMenuStructure createMenu(menuName: string): Promise<{ success: boolean; existed: boolean; menuName: string }> addMenuItemAtPath( parentPath: string[], @@ -60,7 +60,7 @@ export interface Spec extends TurboModule { path: string[], enabled: boolean, ): Promise<{ success: boolean; error?: string }> - readonly onMenuItemPressed: EventEmitter + readonly onMenuItemPressed: EventEmitter } -export default TurboModuleRegistry.getEnforcing("IRMenuItemManager") +export default TurboModuleRegistry.getEnforcing("IRSystemMenuManager") diff --git a/app/utils/useMenuItem.tsx b/app/utils/useSystemMenu.tsx similarity index 77% rename from app/utils/useMenuItem.tsx rename to app/utils/useSystemMenu.tsx index 133efbe..a61a74a 100644 --- a/app/utils/useMenuItem.tsx +++ b/app/utils/useSystemMenu.tsx @@ -53,29 +53,30 @@ import { useEffect, useRef, useCallback, useState } from "react" import { Platform } from "react-native" import { useGlobal } from "../state/useGlobal" -import NativeIRMenuItemManager, { - type MenuItemPressedEvent, - type MenuStructure, - type MenuListEntry, +import NativeIRSystemMenuManager, { + type SystemMenuItemPressedEvent, + type SystemMenuStructure, SEPARATOR, -} from "../native/IRMenuItemManager/NativeIRMenuItemManager" +} from "../native/IRSystemMenuManager/NativeIRSystemMenuManager" // Only thing to configure here is the path separator. const PATH_SEPARATOR = " > " export { SEPARATOR } // Menu separator -export interface MenuItem { +export interface SystemMenuItem { label: string shortcut?: string enabled?: boolean position?: number action?: () => void // Make action optional for items with submenus - submenu?: MenuListEntry[] // Add submenu support + submenu?: SystemMenuListEntry[] // Add submenu support } -export interface MenuItemConfig { - items?: Record +export type SystemMenuListEntry = SystemMenuItem | typeof SEPARATOR + +export interface SystemMenuConfig { + items?: Record remove?: string[] } @@ -87,30 +88,29 @@ const parsePathKey = (key: string): string[] => const joinPath = (p: string[]) => p.join(PATH_SEPARATOR) -const isSeparator = (e: MenuListEntry): e is typeof SEPARATOR => e === SEPARATOR +const isSeparator = (e: SystemMenuListEntry): e is typeof SEPARATOR => e === SEPARATOR -export function useMenuItem(config?: MenuItemConfig) { +export function useSystemMenu(config?: SystemMenuConfig) { const actionsRef = useRef void>>(new Map()) - const previousConfigRef = useRef(null) + const previousConfigRef = useRef(null) const [availableMenus, setAvailableMenus] = useState([]) - const [menuStructure, setMenuStructure] = useState([]) - const [windowsMenuItems, setWindowsMenuItems] = useState>({}) + const [menuStructure, setMenuStructure] = useState([]) // Global state for Windows menu persistence - const [globalMenuConfig, setGlobalMenuConfig] = useGlobal( + const [globalMenuConfig, setGlobalMenuConfig] = useGlobal( "windows-menu-config", null, ) - const [globalMenuStructure, setGlobalMenuStructure] = useGlobal( + const [globalMenuStructure, setGlobalMenuStructure] = useGlobal( "windows-menu-structure", [], ) - const [globalMenuItems, setGlobalMenuItems] = useGlobal>( + const [globalMenuItems, setGlobalMenuItems] = useGlobal>( "windows-menu-items", {}, ) - const handleMenuItemPressed = useCallback((event: MenuItemPressedEvent) => { + const handleMenuItemPressed = useCallback((event: SystemMenuItemPressedEvent) => { const key = joinPath(event.menuPath) const action = actionsRef.current.get(key) if (action) action() @@ -122,7 +122,7 @@ export function useMenuItem(config?: MenuItemConfig) { // For Windows, use global state const configToUse = config || globalMenuConfig if (configToUse?.items) { - const winStructure: MenuStructure = Object.keys(configToUse.items).map((title) => ({ + const winStructure: SystemMenuStructure = Object.keys(configToUse.items).map((title) => ({ title, enabled: true, path: [title], @@ -134,21 +134,16 @@ export function useMenuItem(config?: MenuItemConfig) { if (config && config !== globalMenuConfig) { setGlobalMenuConfig(config) setGlobalMenuStructure(winStructure) - setGlobalMenuItems(config.items as Record) + setGlobalMenuItems(config.items as Record) } // Always use global state for consistency setMenuStructure(globalMenuStructure.length > 0 ? globalMenuStructure : winStructure) - setWindowsMenuItems( - Object.keys(globalMenuItems).length > 0 - ? globalMenuItems - : (configToUse.items as Record), - ) } return [] } else { - const menus = NativeIRMenuItemManager.getAvailableMenus() - const structure = NativeIRMenuItemManager.getMenuStructure() + const menus = NativeIRSystemMenuManager.getAvailableMenus() + const structure = NativeIRSystemMenuManager.getMenuStructure() setAvailableMenus(menus) setMenuStructure(structure) return menus @@ -159,11 +154,11 @@ export function useMenuItem(config?: MenuItemConfig) { } }, [config, globalMenuConfig, globalMenuStructure, globalMenuItems]) - const addEntries = useCallback(async (parentKey: string, entries: MenuListEntry[]) => { + const addEntries = useCallback(async (parentKey: string, entries: SystemMenuListEntry[]) => { const parentPath = parsePathKey(parentKey) try { - await NativeIRMenuItemManager.removeMenuItemAtPath([...parentPath, SEPARATOR]) + await NativeIRSystemMenuManager.removeMenuItemAtPath([...parentPath, SEPARATOR]) } catch (e) { console.warn(`Failed to clear separators for "${parentKey}":`, e) } @@ -171,28 +166,29 @@ export function useMenuItem(config?: MenuItemConfig) { for (const entry of entries) { if (isSeparator(entry)) { try { - await NativeIRMenuItemManager.addMenuItemAtPath(parentPath, SEPARATOR, "") + await NativeIRSystemMenuManager.addMenuItemAtPath(parentPath, SEPARATOR, "") } catch (e) { console.error(`Failed to add separator under "${parentKey}":`, e) } continue } - const item = entry as MenuItem + const item = entry as SystemMenuItem const leafPath = [...parentPath, item.label] const actionKey = joinPath(leafPath) - actionsRef.current.set(actionKey, item.action) + + if (item.action) actionsRef.current.set(actionKey, item.action) try { if (typeof item.position === "number") { - await NativeIRMenuItemManager.insertMenuItemAtPath( + await NativeIRSystemMenuManager.insertMenuItemAtPath( parentPath, item.label, item.position, item.shortcut ?? "", ) } else { - await NativeIRMenuItemManager.addMenuItemAtPath( + await NativeIRSystemMenuManager.addMenuItemAtPath( parentPath, item.label, item.shortcut ?? "", @@ -200,7 +196,7 @@ export function useMenuItem(config?: MenuItemConfig) { } if (item.enabled !== undefined) { - await NativeIRMenuItemManager.setMenuItemEnabledAtPath(leafPath, item.enabled) + await NativeIRSystemMenuManager.setMenuItemEnabledAtPath(leafPath, item.enabled) } } catch (error) { console.error(`Failed to add "${item.label}" under "${parentKey}":`, error) @@ -208,12 +204,12 @@ export function useMenuItem(config?: MenuItemConfig) { } }, []) - const removeMenuItems = useCallback(async (parentKey: string, items: MenuItem[]) => { + const removeMenuItems = useCallback(async (parentKey: string, items: SystemMenuItem[]) => { const parentPath = parsePathKey(parentKey) for (const item of items) { const leafPath = [...parentPath, item.label] try { - await NativeIRMenuItemManager.removeMenuItemAtPath(leafPath) + await NativeIRSystemMenuManager.removeMenuItemAtPath(leafPath) } catch (error) { console.error(`Failed to remove menu item ${joinPath(leafPath)}:`, error) } finally { @@ -225,7 +221,7 @@ export function useMenuItem(config?: MenuItemConfig) { const removeMenuItemByName = useCallback(async (nameOrPath: string) => { const path = parsePathKey(nameOrPath) try { - await NativeIRMenuItemManager.removeMenuItemAtPath(path) + await NativeIRSystemMenuManager.removeMenuItemAtPath(path) actionsRef.current.delete(joinPath(path)) } catch (error) { console.error(`Failed to remove menu item/menu ${nameOrPath}:`, error) @@ -235,7 +231,7 @@ export function useMenuItem(config?: MenuItemConfig) { const setMenuItemEnabled = useCallback(async (pathOrKey: string | string[], enabled: boolean) => { const path = Array.isArray(pathOrKey) ? pathOrKey : parsePathKey(pathOrKey) try { - await NativeIRMenuItemManager.setMenuItemEnabledAtPath(path, enabled) + await NativeIRSystemMenuManager.setMenuItemEnabledAtPath(path, enabled) } catch (error) { console.error(`Failed to set enabled for ${joinPath(path)}:`, error) } @@ -243,7 +239,7 @@ export function useMenuItem(config?: MenuItemConfig) { const getAllMenuPaths = useCallback(async (): Promise => { try { - const structure = NativeIRMenuItemManager.getMenuStructure() + const structure = NativeIRSystemMenuManager.getMenuStructure() const out: string[] = [] const walk = (nodes?: any[]) => { if (!nodes) return @@ -261,16 +257,16 @@ export function useMenuItem(config?: MenuItemConfig) { }, []) const getItemDifference = useCallback( - (oldEntries: MenuListEntry[] = [], newEntries: MenuListEntry[] = []) => { - const oldItems = oldEntries.filter((e): e is MenuItem => !isSeparator(e)) - const newItems = newEntries.filter((e): e is MenuItem => !isSeparator(e)) + (oldEntries: SystemMenuListEntry[] = [], newEntries: SystemMenuListEntry[] = []) => { + const oldItems = oldEntries.filter((e): e is SystemMenuItem => !isSeparator(e)) + const newItems = newEntries.filter((e): e is SystemMenuItem => !isSeparator(e)) - const byLabel = (xs: MenuItem[]) => new Map(xs.map((x) => [x.label, x])) + const byLabel = (xs: SystemMenuItem[]) => new Map(xs.map((x) => [x.label, x])) const oldMap = byLabel(oldItems) const newMap = byLabel(newItems) - const toRemove: MenuItem[] = [] - const toUpdate: MenuItem[] = [] + const toRemove: SystemMenuItem[] = [] + const toUpdate: SystemMenuItem[] = [] for (const [label, item] of newMap) { if (oldMap.has(label)) { @@ -303,7 +299,7 @@ export function useMenuItem(config?: MenuItemConfig) { for (const [parentKey, entries] of Object.entries(config.items)) { for (const entry of entries) { if (!isSeparator(entry)) { - const item = entry as MenuItem + const item = entry as SystemMenuItem const leafPath = [parentKey, item.label] if (item.action) { actionsRef.current.set(joinPath(leafPath), item.action) @@ -337,10 +333,12 @@ export function useMenuItem(config?: MenuItemConfig) { for (const item of toUpdate) { const leafPath = [...parsePathKey(parentKey), item.label] - actionsRef.current.set(joinPath(leafPath), item.action) + if (item.action) { + actionsRef.current.set(joinPath(leafPath), item.action) + } if (item.enabled !== undefined) { try { - await NativeIRMenuItemManager.setMenuItemEnabledAtPath(leafPath, item.enabled) + await NativeIRSystemMenuManager.setMenuItemEnabledAtPath(leafPath, item.enabled) } catch (e) { console.error(`Failed to update ${joinPath(leafPath)}:`, e) } @@ -363,7 +361,7 @@ export function useMenuItem(config?: MenuItemConfig) { discoverMenus() } else { // For macOS, use native menu manager - const subscription = NativeIRMenuItemManager.onMenuItemPressed(handleMenuItemPressed) + const subscription = NativeIRSystemMenuManager.onMenuItemPressed(handleMenuItemPressed) discoverMenus() return () => { subscription.remove() @@ -380,22 +378,22 @@ export function useMenuItem(config?: MenuItemConfig) { const pairs = Object.entries(previousConfigRef.current.items ?? config.items) const cleanup = async () => { for (const [parentKey, entries] of pairs) { - const itemsOnly = entries.filter((e): e is MenuItem => !isSeparator(e)) + const itemsOnly = entries.filter((e): e is SystemMenuItem => !isSeparator(e)) await removeMenuItems(parentKey, itemsOnly) - await NativeIRMenuItemManager.removeMenuItemAtPath([ + await NativeIRSystemMenuManager.removeMenuItemAtPath([ ...parsePathKey(parentKey), SEPARATOR, ]) const parentPath = parsePathKey(parentKey) if (parentPath.length === 1) { const top = parentPath[0] - const structure = NativeIRMenuItemManager.getMenuStructure() + const structure = NativeIRSystemMenuManager.getMenuStructure() const entry = structure.find( (e) => e.title.localeCompare(top, undefined, { sensitivity: "accent" }) === 0, ) if (!entry || !entry.items || entry.items.length === 0) { try { - await NativeIRMenuItemManager.removeMenuItemAtPath([top]) + await NativeIRSystemMenuManager.removeMenuItemAtPath([top]) } catch (e) { console.warn(`Couldn't remove top-level menu "${top}":`, e) } @@ -408,7 +406,7 @@ export function useMenuItem(config?: MenuItemConfig) { }, []) const addMenuItem = useCallback( - async (parentKey: string, item: MenuItem) => { + async (parentKey: string, item: SystemMenuItem) => { await addEntries(parentKey, [item]) }, [addEntries], @@ -421,7 +419,7 @@ export function useMenuItem(config?: MenuItemConfig) { for (const [parentKey, entries] of Object.entries(globalMenuConfig.items)) { for (const entry of entries) { if (!isSeparator(entry)) { - const item = entry as MenuItem + const item = entry as SystemMenuItem const leafPath = [parentKey, item.label] if (item.action) { actionsRef.current.set(joinPath(leafPath), item.action) diff --git a/windows/reactotron/IRNativeModules.g.h b/windows/reactotron/IRNativeModules.g.h index 867c32b..4c3745f 100644 --- a/windows/reactotron/IRNativeModules.g.h +++ b/windows/reactotron/IRNativeModules.g.h @@ -10,10 +10,10 @@ #include "../../app/native/IRClipboard/IRClipboard.windows.h" #include "../../app/native/IRFontList/IRFontList.windows.h" #include "../../app/native/IRKeyboard/IRKeyboard.windows.h" -#include "../../app/native/IRMenuItemManager/IRMenuItemManager.windows.h" #include "../../app/native/IRPassthroughView/IRPassthroughView.windows.h" #include "../../app/native/IRRunShellCommand/IRRunShellCommand.windows.h" #include "../../app/native/IRSystemInfo/IRSystemInfo.windows.h" +#include "../../app/native/IRSystemMenuManager/IRSystemMenuManager.windows.h" #include "../../app/native/IRTabComponentView/IRTabComponentView.windows.h" #include "../../app/utils/experimental/IRExperimental.windows.h" #include "../../app/utils/random/IRRandom.windows.h" From e3cc8ad19ec003cc6a0b0d77c95d6c98ac2cbb25 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Fri, 26 Sep 2025 09:46:15 -0400 Subject: [PATCH 18/23] Fix native types --- app/native/IRSystemMenuManager/IRSystemMenuManager.windows.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/native/IRSystemMenuManager/IRSystemMenuManager.windows.h b/app/native/IRSystemMenuManager/IRSystemMenuManager.windows.h index 88018f3..32edc65 100644 --- a/app/native/IRSystemMenuManager/IRSystemMenuManager.windows.h +++ b/app/native/IRSystemMenuManager/IRSystemMenuManager.windows.h @@ -13,7 +13,7 @@ namespace winrt::reactotron::implementation struct IRSystemMenuManager : reactotronCodegen::IRSystemMenuManagerSpec { // Only the essential types needed for the event - using PressEvent = reactotronCodegen::IRSystemMenuManagerSpec_MenuItemPressedEvent; + using PressEvent = reactotronCodegen::IRSystemMenuManagerSpec_SystemMenuItemPressedEvent; using CreateRet = reactotronCodegen::IRSystemMenuManagerSpec_createMenu_returnType; // One simple method to test event emission From 210bc9a56ea95224666ebad4fb873a10e445dabf Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Fri, 26 Sep 2025 09:46:31 -0400 Subject: [PATCH 19/23] Break out platform implementations --- app/utils/useSystemMenu/types.ts | 21 ++ .../useSystemMenu.macos.ts} | 218 ++++----------- .../useSystemMenu/useSystemMenu.windows.ts | 256 ++++++++++++++++++ app/utils/useSystemMenu/utils.ts | 13 + 4 files changed, 344 insertions(+), 164 deletions(-) create mode 100644 app/utils/useSystemMenu/types.ts rename app/utils/{useSystemMenu.tsx => useSystemMenu/useSystemMenu.macos.ts} (60%) create mode 100644 app/utils/useSystemMenu/useSystemMenu.windows.ts create mode 100644 app/utils/useSystemMenu/utils.ts diff --git a/app/utils/useSystemMenu/types.ts b/app/utils/useSystemMenu/types.ts new file mode 100644 index 0000000..19f3871 --- /dev/null +++ b/app/utils/useSystemMenu/types.ts @@ -0,0 +1,21 @@ +export { SEPARATOR } from "../../native/IRSystemMenuManager/NativeIRSystemMenuManager" +export type { + SystemMenuItemPressedEvent, + SystemMenuStructure, +} from "../../native/IRSystemMenuManager/NativeIRSystemMenuManager" + +export interface SystemMenuItem { + label: string + shortcut?: string + enabled?: boolean + position?: number + action?: () => void + submenu?: SystemMenuListEntry[] +} + +export type SystemMenuListEntry = SystemMenuItem | typeof SEPARATOR + +export interface SystemMenuConfig { + items?: Record + remove?: string[] +} \ No newline at end of file diff --git a/app/utils/useSystemMenu.tsx b/app/utils/useSystemMenu/useSystemMenu.macos.ts similarity index 60% rename from app/utils/useSystemMenu.tsx rename to app/utils/useSystemMenu/useSystemMenu.macos.ts index a61a74a..d6ae931 100644 --- a/app/utils/useSystemMenu.tsx +++ b/app/utils/useSystemMenu/useSystemMenu.macos.ts @@ -42,7 +42,7 @@ * removeMenuItemByName, * setMenuItemEnabled, * getAllMenuPaths - * } = useMenuItem() + * } = useSystemMenu() * * useEffect(() => { * removeMenuItemByName("Format") @@ -51,44 +51,16 @@ */ import { useEffect, useRef, useCallback, useState } from "react" -import { Platform } from "react-native" -import { useGlobal } from "../state/useGlobal" -import NativeIRSystemMenuManager, { +import NativeIRSystemMenuManager from "../../native/IRSystemMenuManager/NativeIRSystemMenuManager" +import { + SEPARATOR, + type SystemMenuItem, + type SystemMenuConfig, + type SystemMenuListEntry, type SystemMenuItemPressedEvent, type SystemMenuStructure, - SEPARATOR, -} from "../native/IRSystemMenuManager/NativeIRSystemMenuManager" - -// Only thing to configure here is the path separator. -const PATH_SEPARATOR = " > " - -export { SEPARATOR } // Menu separator - -export interface SystemMenuItem { - label: string - shortcut?: string - enabled?: boolean - position?: number - action?: () => void // Make action optional for items with submenus - submenu?: SystemMenuListEntry[] // Add submenu support -} - -export type SystemMenuListEntry = SystemMenuItem | typeof SEPARATOR - -export interface SystemMenuConfig { - items?: Record - remove?: string[] -} - -const parsePathKey = (key: string): string[] => - key - .split(PATH_SEPARATOR) - .map((s) => s.trim()) - .filter(Boolean) - -const joinPath = (p: string[]) => p.join(PATH_SEPARATOR) - -const isSeparator = (e: SystemMenuListEntry): e is typeof SEPARATOR => e === SEPARATOR +} from "./types" +import { parsePathKey, joinPath, isSeparator } from "./utils" export function useSystemMenu(config?: SystemMenuConfig) { const actionsRef = useRef void>>(new Map()) @@ -96,20 +68,6 @@ export function useSystemMenu(config?: SystemMenuConfig) { const [availableMenus, setAvailableMenus] = useState([]) const [menuStructure, setMenuStructure] = useState([]) - // Global state for Windows menu persistence - const [globalMenuConfig, setGlobalMenuConfig] = useGlobal( - "windows-menu-config", - null, - ) - const [globalMenuStructure, setGlobalMenuStructure] = useGlobal( - "windows-menu-structure", - [], - ) - const [globalMenuItems, setGlobalMenuItems] = useGlobal>( - "windows-menu-items", - {}, - ) - const handleMenuItemPressed = useCallback((event: SystemMenuItemPressedEvent) => { const key = joinPath(event.menuPath) const action = actionsRef.current.get(key) @@ -118,45 +76,21 @@ export function useSystemMenu(config?: SystemMenuConfig) { const discoverMenus = useCallback(async () => { try { - if (Platform.OS === "windows") { - // For Windows, use global state - const configToUse = config || globalMenuConfig - if (configToUse?.items) { - const winStructure: SystemMenuStructure = Object.keys(configToUse.items).map((title) => ({ - title, - enabled: true, - path: [title], - items: [], - children: [], - })) - - // Update global state if we have a config - if (config && config !== globalMenuConfig) { - setGlobalMenuConfig(config) - setGlobalMenuStructure(winStructure) - setGlobalMenuItems(config.items as Record) - } - - // Always use global state for consistency - setMenuStructure(globalMenuStructure.length > 0 ? globalMenuStructure : winStructure) - } - return [] - } else { - const menus = NativeIRSystemMenuManager.getAvailableMenus() - const structure = NativeIRSystemMenuManager.getMenuStructure() - setAvailableMenus(menus) - setMenuStructure(structure) - return menus - } + const menus = NativeIRSystemMenuManager.getAvailableMenus() + const structure = NativeIRSystemMenuManager.getMenuStructure() + setAvailableMenus(menus) + setMenuStructure(structure) + return menus } catch (error) { console.error("Failed to discover menus:", error) return [] } - }, [config, globalMenuConfig, globalMenuStructure, globalMenuItems]) + }, []) const addEntries = useCallback(async (parentKey: string, entries: SystemMenuListEntry[]) => { const parentPath = parsePathKey(parentKey) + // Clear any existing separators before adding new ones to avoid duplication try { await NativeIRSystemMenuManager.removeMenuItemAtPath([...parentPath, SEPARATOR]) } catch (e) { @@ -241,6 +175,7 @@ export function useSystemMenu(config?: SystemMenuConfig) { try { const structure = NativeIRSystemMenuManager.getMenuStructure() const out: string[] = [] + // Recursively walk the menu tree structure const walk = (nodes?: any[]) => { if (!nodes) return for (const n of nodes) { @@ -292,84 +227,55 @@ export function useSystemMenu(config?: SystemMenuConfig) { const updateMenus = async () => { if (!config) return - if (Platform.OS === "windows") { - // For Windows, update global state and action map - if (config.items) { - // Store actions in actionsRef - for (const [parentKey, entries] of Object.entries(config.items)) { - for (const entry of entries) { - if (!isSeparator(entry)) { - const item = entry as SystemMenuItem - const leafPath = [parentKey, item.label] - if (item.action) { - actionsRef.current.set(joinPath(leafPath), item.action) - } - } - } - } - // Update global state - setGlobalMenuConfig(config) - } - previousConfigRef.current = config - await discoverMenus() - } else { - // Original macOS logic - const previousConfig = previousConfigRef.current - - if (config.remove?.length) { - for (const entry of config.remove) { - await removeMenuItemByName(entry) - } + const previousConfig = previousConfigRef.current + + if (config.remove?.length) { + for (const entry of config.remove) { + await removeMenuItemByName(entry) } + } - if (config.items) { - for (const [parentKey, entries] of Object.entries(config.items)) { - const previousEntries = previousConfig?.items?.[parentKey] || [] - const { toRemove, toUpdate } = getItemDifference(previousEntries, entries) + if (config.items) { + for (const [parentKey, entries] of Object.entries(config.items)) { + const previousEntries = previousConfig?.items?.[parentKey] || [] + const { toRemove, toUpdate } = getItemDifference(previousEntries, entries) - if (toRemove.length) await removeMenuItems(parentKey, toRemove) + if (toRemove.length) await removeMenuItems(parentKey, toRemove) - await addEntries(parentKey, entries) + await addEntries(parentKey, entries) - for (const item of toUpdate) { - const leafPath = [...parsePathKey(parentKey), item.label] - if (item.action) { - actionsRef.current.set(joinPath(leafPath), item.action) - } - if (item.enabled !== undefined) { - try { - await NativeIRSystemMenuManager.setMenuItemEnabledAtPath(leafPath, item.enabled) - } catch (e) { - console.error(`Failed to update ${joinPath(leafPath)}:`, e) - } + for (const item of toUpdate) { + const leafPath = [...parsePathKey(parentKey), item.label] + if (item.action) { + actionsRef.current.set(joinPath(leafPath), item.action) + } + if (item.enabled !== undefined) { + try { + await NativeIRSystemMenuManager.setMenuItemEnabledAtPath(leafPath, item.enabled) + } catch (e) { + console.error(`Failed to update ${joinPath(leafPath)}:`, e) } } } } - - previousConfigRef.current = config - await discoverMenus() } + + previousConfigRef.current = config + await discoverMenus() } updateMenus() - }, [config, addEntries, removeMenuItems, getItemDifference]) + }, [config, addEntries, removeMenuItems, getItemDifference, removeMenuItemByName, discoverMenus]) useEffect(() => { - if (Platform.OS === "windows") { - // For Windows, just discover menus from config - discoverMenus() - } else { - // For macOS, use native menu manager - const subscription = NativeIRSystemMenuManager.onMenuItemPressed(handleMenuItemPressed) - discoverMenus() - return () => { - subscription.remove() - } + const subscription = NativeIRSystemMenuManager.onMenuItemPressed(handleMenuItemPressed) + discoverMenus() + return () => { + subscription.remove() } }, [handleMenuItemPressed, discoverMenus]) - // Clean up old menu items + // Clean up old menu items when component unmounts useEffect(() => { return () => { if (!previousConfigRef.current || !config || !config.items) { @@ -380,11 +286,13 @@ export function useSystemMenu(config?: SystemMenuConfig) { for (const [parentKey, entries] of pairs) { const itemsOnly = entries.filter((e): e is SystemMenuItem => !isSeparator(e)) await removeMenuItems(parentKey, itemsOnly) + // Remove any remaining separators await NativeIRSystemMenuManager.removeMenuItemAtPath([ ...parsePathKey(parentKey), SEPARATOR, ]) const parentPath = parsePathKey(parentKey) + // If this was a top-level menu we created and it's now empty, remove it entirely if (parentPath.length === 1) { const top = parentPath[0] const structure = NativeIRSystemMenuManager.getMenuStructure() @@ -403,7 +311,7 @@ export function useSystemMenu(config?: SystemMenuConfig) { } cleanup() } - }, []) + }, [removeMenuItems]) const addMenuItem = useCallback( async (parentKey: string, item: SystemMenuItem) => { @@ -412,28 +320,10 @@ export function useSystemMenu(config?: SystemMenuConfig) { [addEntries], ) - // For Windows, populate actions from global state if no config provided - useEffect(() => { - if (Platform.OS === "windows" && !config && globalMenuConfig?.items) { - // Restore actions from global config - for (const [parentKey, entries] of Object.entries(globalMenuConfig.items)) { - for (const entry of entries) { - if (!isSeparator(entry)) { - const item = entry as SystemMenuItem - const leafPath = [parentKey, item.label] - if (item.action) { - actionsRef.current.set(joinPath(leafPath), item.action) - } - } - } - } - } - }, [config, globalMenuConfig]) - return { availableMenus, - menuStructure: Platform.OS === "windows" ? globalMenuStructure : menuStructure, - menuItems: Platform.OS === "windows" ? globalMenuItems : {}, + menuStructure, + menuItems: {} as Record, discoverMenus, addMenuItem, removeMenuItemByName, @@ -441,4 +331,4 @@ export function useSystemMenu(config?: SystemMenuConfig) { getAllMenuPaths, handleMenuItemPressed, } -} +} \ No newline at end of file diff --git a/app/utils/useSystemMenu/useSystemMenu.windows.ts b/app/utils/useSystemMenu/useSystemMenu.windows.ts new file mode 100644 index 0000000..76440f7 --- /dev/null +++ b/app/utils/useSystemMenu/useSystemMenu.windows.ts @@ -0,0 +1,256 @@ +/* + * Windows Menu Management (Global State Facade) + * + * Add, delete, and update Windows menu items using global state persistence. + * This implementation provides a facade over global state since Windows doesn't + * have native menu management APIs like macOS. + * + * ────────────────────────────── + * Declarative Usage (via config) + * ────────────────────────────── + * + * const menuConfig = { + * items: { + * "File": [ + * { + * label: "New Project", + * shortcut: "ctrl+n", + * action: () => console.log("New project created"), + * }, + * SEPARATOR, + * { + * label: "Save", + * enabled: false, + * action: () => console.log("Save action"), + * }, + * ], + * "View": [ + * { + * label: "Toggle Sidebar", + * shortcut: "ctrl+b", + * action: () => console.log("Sidebar toggled"), + * }, + * ], + * }, + * remove: ["Help", "Format"], + * } + * + * ─────────────────────────── + * Imperative Usage (via hook) + * ─────────────────────────── + * + * const { + * addMenuItem, + * removeMenuItemByName, + * setMenuItemEnabled, + * getAllMenuPaths, + * menuItems, + * menuStructure + * } = useSystemMenu() + * + * useEffect(() => { + * addMenuItem("Tools", { + * label: "Clear Cache", + * action: () => console.log("Cache cleared") + * }) + * getAllMenuPaths().then(paths => console.log({ paths })) + * }, [addMenuItem, getAllMenuPaths]) + * + * // Note: Windows implementation stores menu state globally for persistence + * // across component unmounts. Actions are stored in actionsRef for execution. + */ + +import { useEffect, useRef, useCallback, useState } from "react" +import { useGlobal } from "../../state/useGlobal" +import { + SEPARATOR, + type SystemMenuItem, + type SystemMenuConfig, + type SystemMenuListEntry, + type SystemMenuItemPressedEvent, + type SystemMenuStructure, +} from "./types" +import { parsePathKey, joinPath, isSeparator } from "./utils" + +export function useSystemMenu(config?: SystemMenuConfig) { + const actionsRef = useRef void>>(new Map()) + + // Global state for Windows menu persistence across component unmounts + const [globalMenuConfig, setGlobalMenuConfig] = useGlobal( + "windows-menu-config", + null, + ) + const [globalMenuStructure, setGlobalMenuStructure] = useGlobal( + "windows-menu-structure", + [], + ) + const [globalMenuItems, setGlobalMenuItems] = useGlobal>( + "windows-menu-items", + {}, + ) + + const handleMenuItemPressed = useCallback((event: SystemMenuItemPressedEvent) => { + const key = joinPath(event.menuPath) + const action = actionsRef.current.get(key) + if (action) action() + }, []) + + const discoverMenus = useCallback(async () => { + const configToUse = config || globalMenuConfig + if (!configToUse?.items || !config || config === globalMenuConfig) return [] + + // Create a simple structure from config items for Windows titlebar rendering + const winStructure: SystemMenuStructure = Object.keys(configToUse.items).map((title) => ({ + title, + enabled: true, + path: [title], + items: [], + children: [], + })) + + // Update global state if we have a new config + setGlobalMenuConfig(config) + setGlobalMenuStructure(winStructure) + setGlobalMenuItems(config.items as Record) + + return [] // Windows doesn't have native menu discovery + }, [config, globalMenuConfig, setGlobalMenuConfig, setGlobalMenuStructure, setGlobalMenuItems]) + + const addMenuItem = useCallback( + async (parentKey: string, item: SystemMenuItem) => { + const leafPath = [parentKey, item.label] + const actionKey = joinPath(leafPath) + + // Store action in memory for execution when menu item is pressed + if (item.action) { + actionsRef.current.set(actionKey, item.action) + } + + // Add item to global state for UI rendering + setGlobalMenuItems((prev) => ({ + ...prev, + [parentKey]: [...(prev[parentKey] || []), item], + })) + }, + [setGlobalMenuItems], + ) + + const removeMenuItemByName = useCallback( + async (nameOrPath: string) => { + const path = parsePathKey(nameOrPath) + const key = joinPath(path) + // Remove action from memory + actionsRef.current.delete(key) + + // Remove from global state based on path depth + if (path.length === 1) { + // Remove entire top-level menu + setGlobalMenuItems((prev) => { + const updated = { ...prev } + delete updated[path[0]] + return updated + }) + } else if (path.length === 2) { + // Remove specific menu item + const [parentKey, itemLabel] = path + setGlobalMenuItems((prev) => ({ + ...prev, + [parentKey]: (prev[parentKey] || []).filter((item) => item.label !== itemLabel), + })) + } + }, + [setGlobalMenuItems], + ) + + const setMenuItemEnabled = useCallback( + async (pathOrKey: string | string[], enabled: boolean) => { + const path = Array.isArray(pathOrKey) ? pathOrKey : parsePathKey(pathOrKey) + + // Update enabled state in global state (Windows only supports in-memory state updates) + if (path.length >= 2) { + const [parentKey, itemLabel] = path + setGlobalMenuItems((prev) => ({ + ...prev, + [parentKey]: (prev[parentKey] || []).map((item) => + item.label === itemLabel ? { ...item, enabled } : item, + ), + })) + } + }, + [setGlobalMenuItems], + ) + + const getAllMenuPaths = useCallback(async (): Promise => { + const paths: string[] = [] + for (const [parentKey, entries] of Object.entries(globalMenuItems)) { + for (const entry of entries) { + if (!isSeparator(entry)) { + paths.push(joinPath([parentKey, entry.label])) + } + } + } + return paths + }, [globalMenuItems]) + + // Update menus when config changes + useEffect(() => { + const updateMenus = async () => { + if (!config) return + + if (config.items) { + // Store actions in memory for execution + for (const [parentKey, entries] of Object.entries(config.items)) { + for (const entry of entries) { + if (!isSeparator(entry)) { + const item = entry as SystemMenuItem + const leafPath = [parentKey, item.label] + if (item.action) { + actionsRef.current.set(joinPath(leafPath), item.action) + } + } + } + } + // Update global state for UI persistence + setGlobalMenuConfig(config) + setGlobalMenuItems(config.items as Record) + } + await discoverMenus() + } + + updateMenus() + }, [config, setGlobalMenuConfig, setGlobalMenuItems, discoverMenus]) + + // Restore actions from global state when no config is provided (component remount) + useEffect(() => { + if (!config && globalMenuConfig?.items) { + // Restore actions from persisted global config + for (const [parentKey, entries] of Object.entries(globalMenuConfig.items)) { + for (const entry of entries) { + if (!isSeparator(entry)) { + const item = entry as SystemMenuItem + const leafPath = [parentKey, item.label] + if (item.action) { + actionsRef.current.set(joinPath(leafPath), item.action) + } + } + } + } + } + }, [config, globalMenuConfig]) + + useEffect(() => { + discoverMenus() + }, [discoverMenus]) + + return { + availableMenus: [], // Windows doesn't have native menu discovery + menuStructure: globalMenuStructure, + menuItems: globalMenuItems, + discoverMenus, + addMenuItem, + removeMenuItemByName, + setMenuItemEnabled, + getAllMenuPaths, + handleMenuItemPressed, + } +} \ No newline at end of file diff --git a/app/utils/useSystemMenu/utils.ts b/app/utils/useSystemMenu/utils.ts new file mode 100644 index 0000000..a20a844 --- /dev/null +++ b/app/utils/useSystemMenu/utils.ts @@ -0,0 +1,13 @@ +import { SEPARATOR, type SystemMenuListEntry } from "./types" + +export const PATH_SEPARATOR = " > " + +export const parsePathKey = (key: string): string[] => + key + .split(PATH_SEPARATOR) + .map((s) => s.trim()) + .filter(Boolean) + +export const joinPath = (p: string[]) => p.join(PATH_SEPARATOR) + +export const isSeparator = (e: SystemMenuListEntry): e is typeof SEPARATOR => e === SEPARATOR \ No newline at end of file From 10e6a5193f269c87d0ee440c795e8b2db4c81781 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Fri, 26 Sep 2025 11:00:19 -0400 Subject: [PATCH 20/23] improve and document the useGlobal windows hook --- app/state/useGlobal.windows.ts | 249 ++++++++++++++++++++++++++++----- 1 file changed, 215 insertions(+), 34 deletions(-) diff --git a/app/state/useGlobal.windows.ts b/app/state/useGlobal.windows.ts index edbc94b..cd22dd1 100644 --- a/app/state/useGlobal.windows.ts +++ b/app/state/useGlobal.windows.ts @@ -1,92 +1,273 @@ -// globalStore.ts -import { useSyncExternalStore, useCallback } from "react"; +/** + * Global State Management System (React Native Windows) + * + * A lightweight global state solution built on React's useSyncExternalStore. + * Provides both hook-based and imperative access to shared state values. + * + * Features: + * - Type-safe global state with "initialize on first read" semantics + * - Efficient subscription system with automatic cleanup + * - Batched updates to minimize re-renders + * - Support for both React components (useGlobal) and external code (withGlobal) + * - Memory leak prevention via Set cleanup + * - No-op persistence stubs (API-stable; can be wired later) + * + * Usage: + * const [count, setCount] = useGlobal('counter', 0); + * const [data, setData] = withGlobal('userData', {}); + * const [persistedData, setPersisted] = useGlobal('key', {}, { persist: true }); // persist currently no-op + * + * Notes: + * - `initialValue` is used as the value if the key is missing. For components, + * we DO NOT write during render; we write once post-mount if still missing. + * - If different `initialValue`s are passed for the same `id`, the first + * established value "wins" (subsequent differing defaults are ignored). + */ + +import { useSyncExternalStore, useEffect, useCallback } from "react"; import { unstable_batchedUpdates } from "react-native"; type Id = string; type Listener = () => void; type SetValue = T | ((prev: T) => T); +type UseGlobalOptions = { persist?: boolean }; +/* ----------------------------------------------------------------------------- + * Internal Stores + * -------------------------------------------------------------------------- */ +// Central storage for all global state values, keyed by unique identifiers const globals = new Map(); +// Subscription system: maps each global ID to a set of listener functions const listeners = new Map>(); -// Initialize global value if it doesn't exist, but don't modify during snapshot reads -function initializeGlobal(id: Id, initialValue: T): void { - if (!globals.has(id)) { - globals.set(id, initialValue); - } +/* ----------------------------------------------------------------------------- + * Persistence Stubs (no-op) + * RN Web doesn't have react-native-mmkv support out of the box; plan to wire later + * -------------------------------------------------------------------------- */ +function loadPersistedGlobals(): void { + // No-op: persistence not implemented +} + +function debouncePersist(_delay: number = 300): void { + // No-op: persistence not implemented } +/* ----------------------------------------------------------------------------- + * Helpers + * -------------------------------------------------------------------------- */ + +/** + * Read a snapshot for an id, returning `initialValue` when missing. + * Pure read: NEVER writes during render. + */ +function getSnapshotWithDefault(id: Id, initialValue: T): T { + return (globals.has(id) ? (globals.get(id) as T) : initialValue); +} + +/** + * Read a snapshot without default (used by imperative API and setters). + */ function getSnapshot(id: Id): T { return globals.get(id) as T; } +/** + * Subscribe a component to changes for a specific global ID. + * Returns an unsubscribe function that cleans up both the listener and empty sets. + */ function subscribe(id: Id, cb: Listener): () => void { let set = listeners.get(id); if (!set) listeners.set(id, (set = new Set())); set.add(cb); + + // Return cleanup function that prevents memory leaks return () => { const s = listeners.get(id); if (!s) return; s.delete(cb); + // Clean up empty listener sets to prevent memory leaks if (s.size === 0) listeners.delete(id); }; } +/** + * Notify all subscribers of a global value change. + * Uses batched updates to prevent excessive re-renders when multiple globals change. + * Iterates over a copy to be resilient to listeners mutating subscriptions. + */ function notify(id: Id) { const s = listeners.get(id); if (!s || s.size === 0) return; + unstable_batchedUpdates(() => { - for (const l of s) l(); + for (const l of Array.from(s)) l(); }); } -export function useGlobal(id: Id, initialValue: T): [T, (v: SetValue) => void] { - // Initialize the global value once, outside of the snapshot function - initializeGlobal(id, initialValue); +/** + * Create a setter function that handles state updates (persistence is no-op). + * - Supports functional updates like React.useState + * - Skips notifications if value is Object.is-equal (no-op update) + * - Accepts `null` to reset/delete the value (used by imperative API) + */ +function buildSetValue(id: Id, persist: boolean) { + return (value: SetValue | null) => { + const prev = globals.get(id) as T | undefined; + + // Handle null value as reset (imperative API) + if (value === null) { + if (!globals.has(id)) return; // nothing to reset + globals.delete(id); + // persistence cleanup would go here (no-op for now) + notify(id); + return; + } + + // Resolve functional updater + const next = + typeof value === "function" + ? (value as (prev: T) => T)(getSnapshot(id)) + : value; + + // Avoid unnecessary notifications/re-renders on no-op updates + if (Object.is(prev, next)) return; + globals.set(id, next); + + // Would save to persistent storage if implemented (no-op for now) + if (persist) debouncePersist(); + + // Notify all subscribers for re-renders + notify(id); + }; +} + +/* ----------------------------------------------------------------------------- + * Public API + * -------------------------------------------------------------------------- */ + +/** + * React hook for accessing and updating global state (component-friendly API) + * + * RN-only: No SSR getServerSnapshot is provided. + * + * @param id - Unique identifier for the global value + * @param initialValue - Default value to use if the global doesn't exist yet + * @param options - Configuration options including persistence (NOTE: persist is no-op) + * @returns Tuple of [currentValue, setter] similar to useState + */ +export function useGlobal( + id: Id, + initialValue: T, + { persist = false }: UseGlobalOptions = {} +): [T, (v: SetValue) => void] { + // Read via useSyncExternalStore; ensure the snapshot read is PURE (no writes) const value = useSyncExternalStore( - (cb) => subscribe(id, cb), - () => getSnapshot(id) + (cb) => subscribe(id, cb), // subscribe + () => getSnapshotWithDefault(id, initialValue) // getSnapshot (client) ); - // Memoize the setter function to prevent unnecessary re-renders - const set = useCallback((next: SetValue) => { - const current = getSnapshot(id); - const resolved = typeof next === "function" ? (next as (p: T) => T)(current) : next; - globals.set(id, resolved); - notify(id); + /** + * Post-mount initialization: + * If the key is still missing, establish it with `initialValue`. + * This avoids writes during render while ensuring the key exists thereafter. + * No notify needed: subscribers already read `initialValue` on first render. + */ + useEffect(() => { + if (!globals.has(id)) { + globals.set(id, initialValue); + // Optionally, a dev-only warning could detect mismatched defaults for same id. + } + // We intentionally do not depend on initialValue here: + // changing the default later should not rewrite established globals. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [id]); + // Memoize the setter; enforce non-null signature for hook users + const setAny = useCallback(buildSetValue(id, persist), [id, persist]); + const set = useCallback<(v: SetValue) => void>((v) => setAny(v), [setAny]); + return [value, set]; } -// Imperative access (outside components) +/** + * Imperative access to global state (for use outside React components) + * + * Useful for accessing global state in utility functions, event handlers, + * or other non-React code that needs to read/write global values. + * + * @param id - Unique identifier for the global value + * @param initialValue - Default value to use if the global doesn't exist yet + * @param options - Configuration options including persistence (NOTE: persist is no-op) + * @returns Tuple of [currentValue, setter] where setter accepts null to reset + */ export function withGlobal( id: Id, - initialValue: T + initialValue: T, + { persist = false }: UseGlobalOptions = {} ): [T, (v: SetValue | null) => void] { - // Initialize the global value - initializeGlobal(id, initialValue); - - const setter = (v: SetValue | null) => { - if (v === null) return resetGlobal(id); - const current = getSnapshot(id); - const resolved = typeof v === "function" ? (v as (p: T) => T)(current) : v; - globals.set(id, resolved); - notify(id); - }; - return [getSnapshot(id), setter]; + // Imperative path can initialize synchronously without render concerns + if (!globals.has(id)) globals.set(id, initialValue); + return [getSnapshot(id), buildSetValue(id, persist)]; } +/** + * Reset a specific global value back to its initial state (delete the key). + * + * @param id - The global identifier to reset + * @param rerender - Whether to trigger re-renders (default: true) + */ export function resetGlobal(id: Id, rerender = true) { + if (!globals.has(id)) return; globals.delete(id); + // Note: No persistence cleanup needed since persistence is not implemented if (rerender) notify(id); } +/** + * Clear all global state values. + * + * Useful for testing or app-wide state resets. Only notifies globals + * that have active listeners to avoid unnecessary work. + * + * @param rerender - Whether to trigger re-renders (default: true) + */ export function clearGlobals(rerender = true) { + // Clear in-memory state + const hadAny = globals.size > 0; globals.clear(); - if (rerender) { - // Only notify ids that have listeners; avoids stale maps + // Note: No persistent storage to clear since persistence is not implemented + + if (rerender && hadAny) { + // Only notify ids that currently have listeners for (const id of listeners.keys()) notify(id); } } + +/* ----------------------------------------------------------------------------- + * Optional Developer Ergonomics (handy for tests & utilities) + * -------------------------------------------------------------------------- */ + +/** + * Read a global value without subscribing. Returns undefined if missing. + */ +export const getGlobal = (id: Id): T | undefined => + (globals.get(id) as T | undefined); + +/** + * Set a global value without subscribing. (Non-null only.) + */ +export const setGlobal = (id: Id, v: SetValue): void => + buildSetValue(id, false)(v); + +/** + * Check whether a global key exists. + */ +export const hasGlobal = (id: Id): boolean => globals.has(id); + +/* ----------------------------------------------------------------------------- + * Module Initialization + * -------------------------------------------------------------------------- */ + +// Load persisted globals on module initialization (no-op for now) +loadPersistedGlobals(); From a5e2ac3881b766627f2e18da388ca0184a11abbc Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Fri, 26 Sep 2025 11:00:40 -0400 Subject: [PATCH 21/23] Remove RNW useKeyboard return --- app/utils/system.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/utils/system.ts b/app/utils/system.ts index dcc66be..18b1e3a 100644 --- a/app/utils/system.ts +++ b/app/utils/system.ts @@ -47,7 +47,6 @@ export function useKeyboardEvents(onKeyboardEvent: (event: KeyboardEvent) => voi const keyboardSubscription = useRef(null) useEffect(() => { - if (Platform.OS === "windows") return _keyboardSubscribers++ if (_keyboardSubscribers === 1) IRKeyboard.startListening() keyboardSubscription.current = IRKeyboard.onKeyboardEvent(onKeyboardEvent) From c9f3ea50e8388d53dc11e9a936ea8deb3a9dbe02 Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Fri, 26 Sep 2025 11:00:57 -0400 Subject: [PATCH 22/23] fix imports --- app/app.tsx | 14 +++++++------- app/components/Titlebar/TitlebarMenu.tsx | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/app.tsx b/app/app.tsx index 0b5378a..20c6898 100644 --- a/app/app.tsx +++ b/app/app.tsx @@ -9,7 +9,7 @@ import { connectToServer } from "./state/connectToServer" import { useTheme, themed } from "./theme/theme" import { useEffect, useMemo } from "react" import { TimelineScreen } from "./screens/TimelineScreen" -import { useSystemMenu } from "./utils/useSystemMenu" +import { useSystemMenu } from "./utils/useSystemMenu/useSystemMenu" import { Titlebar } from "./components/Titlebar/Titlebar" import { Sidebar } from "./components/Sidebar/Sidebar" import { useSidebar } from "./state/useSidebar" @@ -74,12 +74,12 @@ function App(): React.JSX.Element { }, ...(__DEV__ ? [ - { - label: "Toggle Dev Menu", - shortcut: "cmd+shift+d", - action: () => NativeModules.DevMenu.show(), - }, - ] + { + label: "Toggle Dev Menu", + shortcut: "cmd+shift+d", + action: () => NativeModules.DevMenu.show(), + }, + ] : []), ], Window: [ diff --git a/app/components/Titlebar/TitlebarMenu.tsx b/app/components/Titlebar/TitlebarMenu.tsx index ee446e3..60b48ce 100644 --- a/app/components/Titlebar/TitlebarMenu.tsx +++ b/app/components/Titlebar/TitlebarMenu.tsx @@ -6,7 +6,7 @@ import { MenuDropdown } from "../Menu/MenuDropdown" import { MenuOverlay } from "../Menu/MenuOverlay" import type { Position } from "../Menu/types" import { PassthroughView } from "./PassthroughView" -import { useSystemMenu } from "../../utils/useSystemMenu" +import { useSystemMenu } from "../../utils/useSystemMenu/useSystemMenu" export const TitlebarMenu = () => { const { menuStructure, menuItems, handleMenuItemPressed } = useSystemMenu() From fbd9f0f7402ef8f3e5cc3b66e7c3660a3da59cfa Mon Sep 17 00:00:00 2001 From: Sean Barker Date: Fri, 26 Sep 2025 11:01:11 -0400 Subject: [PATCH 23/23] rename --- .../useSystemMenu/{useSystemMenu.macos.ts => useSystemMenu.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/utils/useSystemMenu/{useSystemMenu.macos.ts => useSystemMenu.ts} (100%) diff --git a/app/utils/useSystemMenu/useSystemMenu.macos.ts b/app/utils/useSystemMenu/useSystemMenu.ts similarity index 100% rename from app/utils/useSystemMenu/useSystemMenu.macos.ts rename to app/utils/useSystemMenu/useSystemMenu.ts