From 7f5add1a0de3d9c63f429e5ccc87abdf8bc7626c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:06:11 +0000 Subject: [PATCH 1/5] Initial plan From 14d0088ac1f57526d00f2d11f7514eed74e09a34 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:14:23 +0000 Subject: [PATCH 2/5] Add FileWatcher class and stylesheet hotloading support in DEBUG builds Co-authored-by: Xenthio <28588188+Xenthio@users.noreply.github.com> --- src/Fazor.UI/FazorApplication.cs | 9 + src/Fazor.UI/NativeWindow.cs | 3 + src/Sandbox.UI/Panel/RootPanel.cs | 18 ++ src/Sandbox.UI/Styles/StyleSheet.cs | 63 ++++- src/Sandbox.UI/Utility/FileWatcher.cs | 323 ++++++++++++++++++++++++++ 5 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 src/Sandbox.UI/Utility/FileWatcher.cs diff --git a/src/Fazor.UI/FazorApplication.cs b/src/Fazor.UI/FazorApplication.cs index 2ba8ff82..f577c084 100644 --- a/src/Fazor.UI/FazorApplication.cs +++ b/src/Fazor.UI/FazorApplication.cs @@ -245,6 +245,15 @@ public static void Run(RootPanel rootPanel, int width = 1280, int height = 720, nativeWindow.RootPanel = rootPanel; +#if DEBUG + // Set up hotloading: when stylesheets change, dirty all styles on the root panel + StyleSheet.OnStyleSheetsHotloaded += () => + { + Console.WriteLine("[Hotload] Stylesheets changed - refreshing all panel styles"); + rootPanel.DirtyAllStyles(); + }; +#endif + // Initialize window manager for multi-window support (create once globally) if (_windowManager == null) { diff --git a/src/Fazor.UI/NativeWindow.cs b/src/Fazor.UI/NativeWindow.cs index 3ec292ff..f6897913 100644 --- a/src/Fazor.UI/NativeWindow.cs +++ b/src/Fazor.UI/NativeWindow.cs @@ -233,6 +233,9 @@ private void OnRender(double delta) { if (RootPanel == null) return; + // Process file watcher notifications (DEBUG builds only - handles stylesheet/Razor hotloading) + FileWatcher.Tick((float)delta); + // Update panel time for transitions and animations PanelRealTime.Update(delta); RealTime.Update(delta); diff --git a/src/Sandbox.UI/Panel/RootPanel.cs b/src/Sandbox.UI/Panel/RootPanel.cs index e6414e36..bb601a23 100644 --- a/src/Sandbox.UI/Panel/RootPanel.cs +++ b/src/Sandbox.UI/Panel/RootPanel.cs @@ -341,4 +341,22 @@ internal void BuildStyleRules() styleRuleUpdates.Clear(); } + + /// + /// Dirty all styles on this root panel and its children. + /// Called when stylesheets are hotloaded to force a complete style rebuild. + /// + internal void DirtyAllStyles() + { + DirtyStylesRecursive(); + } + + /// + /// Call OnHotloaded on this panel and all children. + /// Called when razor files or stylesheets are hotloaded. + /// + internal void TriggerHotload() + { + OnHotloaded(); + } } diff --git a/src/Sandbox.UI/Styles/StyleSheet.cs b/src/Sandbox.UI/Styles/StyleSheet.cs index 3e2abc94..37cac413 100644 --- a/src/Sandbox.UI/Styles/StyleSheet.cs +++ b/src/Sandbox.UI/Styles/StyleSheet.cs @@ -4,6 +4,12 @@ public class StyleSheet { public static List Loaded { get; internal set; } = new List(); + /// + /// Event raised when any stylesheet is hotloaded. + /// Used by the UI system to invalidate all panel styles. + /// + public static event Action? OnStyleSheetsHotloaded; + /// /// Between sessions we clear the stylesheets, so one gamemode can't accidentally /// use cached values from another. @@ -20,6 +26,7 @@ internal static void InitStyleSheets() public List Nodes { get; set; } = new List(); public string FileName { get; internal set; } + internal FileWatcher? Watcher { get; private set; } public List IncludedFiles { get; set; } = new List(); public Dictionary Variables; public Dictionary KeyFrames = new Dictionary( StringComparer.OrdinalIgnoreCase ); @@ -29,7 +36,8 @@ internal static void InitStyleSheets() /// public void Release() { - // File watching not implemented in this port + Watcher?.Dispose(); + Watcher = null; } public static StyleSheet FromFile( string filename, IEnumerable<(string key, string value)> variables = null, bool failSilently = false ) @@ -46,6 +54,7 @@ public static StyleSheet FromFile( string filename, IEnumerable<(string key, str sheet.AddVariables( variables ); sheet.FileName = filename; + sheet.AddWatcher( filename ); Loaded.Add( sheet ); @@ -55,6 +64,7 @@ public static StyleSheet FromFile( string filename, IEnumerable<(string key, str internal void AddFilename( string filename ) { IncludedFiles.Add( filename ); + Watcher?.AddFile( filename ); } public static StyleSheet FromString( string styles, string filename = "none", IEnumerable<(string key, string value)> variables = null ) @@ -211,4 +221,55 @@ public void AddKeyFrames( KeyFrames frames ) { KeyFrames[frames.Name] = frames; } + + /// + /// Set up file watching for this stylesheet (DEBUG builds only). + /// + private void AddWatcher( string name ) + { +#if DEBUG + Watcher?.Dispose(); + Watcher = null; + + var fullPath = name; + if ( !System.IO.Path.IsPathRooted( fullPath ) ) + { + fullPath = System.IO.Path.GetFullPath( name ); + } + + if ( !System.IO.File.Exists( fullPath ) ) + return; + + // Store the filename for reloading + var watchFileName = fullPath; + + Watcher = new FileWatcher(); + Watcher.AddFile( fullPath ); + + // Add any included files to the watcher + foreach ( var file in IncludedFiles ) + { + var includePath = file; + if ( !System.IO.Path.IsPathRooted( includePath ) ) + { + // Try relative to the main stylesheet + var dir = System.IO.Path.GetDirectoryName( fullPath ); + if ( !string.IsNullOrEmpty( dir ) ) + { + includePath = System.IO.Path.Combine( dir, file ); + } + } + Watcher.AddFile( includePath ); + } + + Watcher.OnChanges += ( watcher ) => + { + Console.WriteLine( $"[StyleSheet] Hotloading: {watchFileName}" ); + UpdateFromFile( watchFileName, true ); + + // Notify the UI system that stylesheets have changed + OnStyleSheetsHotloaded?.Invoke(); + }; +#endif + } } diff --git a/src/Sandbox.UI/Utility/FileWatcher.cs b/src/Sandbox.UI/Utility/FileWatcher.cs new file mode 100644 index 00000000..b5a8c3fa --- /dev/null +++ b/src/Sandbox.UI/Utility/FileWatcher.cs @@ -0,0 +1,323 @@ +namespace Sandbox.UI; + +/// +/// Watch files and dispatch events when they change. +/// Based on S&box's FileWatch pattern for stylesheet and Razor hotloading. +/// Only active in DEBUG builds to avoid overhead in release. +/// +public sealed class FileWatcher : IDisposable +{ +#if DEBUG + private static readonly object _lock = new(); + private static readonly List _watchers = new(); + private static readonly HashSet _changedFiles = new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary _directoryWatchers = new(); + private static float _timeSinceLastChange = 0f; + private static float _debounceTime = 0.2f; // 200ms debounce to batch rapid file changes + private static bool _hasChanges = false; + + private readonly List _watchedFiles = new(); + private bool _disposed; + + /// + /// Event raised when any watched file changes. + /// The FileWatcher argument contains the list of changed files. + /// + public event Action? OnChanges; + + /// + /// Event raised for each individual file that changed. + /// + public event Action? OnChangedFile; + + /// + /// The files that changed since the last callback. + /// + public List Changes { get; private set; } = new(); + + /// + /// Whether this watcher is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Creates a new file watcher. + /// + public FileWatcher() + { + lock (_lock) + { + _watchers.Add(this); + } + } + + /// + /// Add a file to watch for changes. + /// + public void AddFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + return; + + // Normalize the path + filePath = NormalizePath(filePath); + + if (!_watchedFiles.Contains(filePath)) + { + _watchedFiles.Add(filePath); + } + + // Ensure we have a watcher for this file's directory + EnsureDirectoryWatcher(filePath); + } + + /// + /// Remove a file from being watched. + /// + public void RemoveFile(string filePath) + { + if (string.IsNullOrWhiteSpace(filePath)) + return; + + filePath = NormalizePath(filePath); + _watchedFiles.Remove(filePath); + } + + /// + /// Check if this watcher is interested in a specific file. + /// + private bool InterestedInFile(string file) + { + if (_watchedFiles.Count == 0) + return true; // No specific files = watch all + + return _watchedFiles.Any(f => + string.Equals(f, file, StringComparison.OrdinalIgnoreCase) || + file.EndsWith(f, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Ensure we have a FileSystemWatcher for the directory containing this file. + /// + private static void EnsureDirectoryWatcher(string filePath) + { + var directory = Path.GetDirectoryName(filePath); + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + return; + + lock (_lock) + { + if (_directoryWatchers.ContainsKey(directory)) + return; + + try + { + var watcher = new FileSystemWatcher(directory) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName | NotifyFilters.Size, + IncludeSubdirectories = false, + EnableRaisingEvents = true + }; + + watcher.Changed += OnFileChanged; + watcher.Created += OnFileChanged; + watcher.Renamed += OnFileRenamed; + watcher.Error += OnWatcherError; + + _directoryWatchers[directory] = watcher; + + Console.WriteLine($"[FileWatcher] Started watching: {directory}"); + } + catch (Exception ex) + { + Console.WriteLine($"[FileWatcher] Failed to watch directory '{directory}': {ex.Message}"); + } + } + } + + private static void OnFileChanged(object sender, FileSystemEventArgs e) + { + AddChangedFile(e.FullPath); + } + + private static void OnFileRenamed(object sender, RenamedEventArgs e) + { + AddChangedFile(e.OldFullPath); + AddChangedFile(e.FullPath); + } + + private static void OnWatcherError(object sender, ErrorEventArgs e) + { + Console.WriteLine($"[FileWatcher] Error: {e.GetException()?.Message}"); + } + + private static void AddChangedFile(string filePath) + { + filePath = NormalizePath(filePath); + + // Ignore temp files + if (filePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase) || + filePath.EndsWith("~", StringComparison.OrdinalIgnoreCase)) + return; + + lock (_lock) + { + _changedFiles.Add(filePath); + _timeSinceLastChange = 0f; + _hasChanges = true; + } + } + + private static string NormalizePath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + return path; + + // Get full path and normalize separators + try + { + path = Path.GetFullPath(path); + } + catch + { + // If GetFullPath fails, just normalize slashes + } + + return path.Replace('\\', '/'); + } + + /// + /// Must be called from the main thread each frame to process file change notifications. + /// This is typically called from the render loop. + /// + /// Time since last tick in seconds + public static void Tick(float deltaTime) + { + if (!_hasChanges) + return; + + _timeSinceLastChange += deltaTime; + + // Wait for debounce period to batch rapid file saves + if (_timeSinceLastChange < _debounceTime) + return; + + List? changedFiles = null; + List? watchers = null; + + lock (_lock) + { + if (_changedFiles.Count == 0) + { + _hasChanges = false; + return; + } + + changedFiles = _changedFiles.ToList(); + _changedFiles.Clear(); + _hasChanges = false; + + watchers = _watchers.ToList(); + } + + // Log what changed + foreach (var file in changedFiles) + { + Console.WriteLine($"[FileWatcher] File changed: {file}"); + } + + // Notify watchers + foreach (var watcher in watchers) + { + if (!watcher.Enabled || watcher._disposed) + continue; + + watcher.TriggerCallback(changedFiles); + } + } + + private void TriggerCallback(List changedFiles) + { + Changes.Clear(); + + foreach (var file in changedFiles) + { + if (InterestedInFile(file)) + { + Changes.Add(file); + } + } + + if (Changes.Count == 0) + return; + + try + { + OnChanges?.Invoke(this); + + foreach (var file in Changes) + { + OnChangedFile?.Invoke(file); + } + } + catch (Exception ex) + { + Console.WriteLine($"[FileWatcher] Error in callback: {ex.Message}"); + } + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + OnChanges = null; + OnChangedFile = null; + + lock (_lock) + { + _watchers.Remove(this); + } + } + + /// + /// Dispose all directory watchers. Call on application shutdown. + /// + public static void DisposeAll() + { + lock (_lock) + { + foreach (var watcher in _directoryWatchers.Values) + { + try + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + catch { } + } + _directoryWatchers.Clear(); + + foreach (var w in _watchers.ToList()) + { + w.Dispose(); + } + _watchers.Clear(); + _changedFiles.Clear(); + } + } +#else + // Release build stubs - no-op implementations + public event Action? OnChanges; + public event Action? OnChangedFile; + public List Changes { get; } = new(); + public bool Enabled { get; set; } = false; + + public void AddFile(string filePath) { } + public void RemoveFile(string filePath) { } + public void Dispose() { } + public static void Tick(float deltaTime) { } + public static void DisposeAll() { } +#endif +} From f4646950fab760ac85c5d0d43bac1421ff93c8d3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:19:20 +0000 Subject: [PATCH 3/5] Address code review feedback - improve file matching and use Log class Co-authored-by: Xenthio <28588188+Xenthio@users.noreply.github.com> --- src/Sandbox.UI/Utility/FileWatcher.cs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/src/Sandbox.UI/Utility/FileWatcher.cs b/src/Sandbox.UI/Utility/FileWatcher.cs index b5a8c3fa..a390c432 100644 --- a/src/Sandbox.UI/Utility/FileWatcher.cs +++ b/src/Sandbox.UI/Utility/FileWatcher.cs @@ -85,15 +85,21 @@ public void RemoveFile(string filePath) /// /// Check if this watcher is interested in a specific file. + /// Uses exact path matching to avoid false positives. /// private bool InterestedInFile(string file) { + // If no specific files registered, require explicit file registration + // to avoid excessive callbacks. This is a safety measure. if (_watchedFiles.Count == 0) - return true; // No specific files = watch all + return false; + // Normalize for comparison + file = NormalizePath(file); + + // Use exact path matching only return _watchedFiles.Any(f => - string.Equals(f, file, StringComparison.OrdinalIgnoreCase) || - file.EndsWith(f, StringComparison.OrdinalIgnoreCase)); + string.Equals(f, file, StringComparison.OrdinalIgnoreCase)); } /// @@ -126,11 +132,11 @@ private static void EnsureDirectoryWatcher(string filePath) _directoryWatchers[directory] = watcher; - Console.WriteLine($"[FileWatcher] Started watching: {directory}"); + Log.Info($"[FileWatcher] Started watching: {directory}"); } catch (Exception ex) { - Console.WriteLine($"[FileWatcher] Failed to watch directory '{directory}': {ex.Message}"); + Log.Warning($"[FileWatcher] Failed to watch directory '{directory}': {ex.Message}"); } } } @@ -148,7 +154,7 @@ private static void OnFileRenamed(object sender, RenamedEventArgs e) private static void OnWatcherError(object sender, ErrorEventArgs e) { - Console.WriteLine($"[FileWatcher] Error: {e.GetException()?.Message}"); + Log.Warning($"[FileWatcher] Error: {e.GetException()?.Message}"); } private static void AddChangedFile(string filePath) @@ -223,7 +229,7 @@ public static void Tick(float deltaTime) // Log what changed foreach (var file in changedFiles) { - Console.WriteLine($"[FileWatcher] File changed: {file}"); + Log.Info($"[FileWatcher] File changed: {file}"); } // Notify watchers @@ -262,7 +268,7 @@ private void TriggerCallback(List changedFiles) } catch (Exception ex) { - Console.WriteLine($"[FileWatcher] Error in callback: {ex.Message}"); + Log.Warning($"[FileWatcher] Error in callback: {ex.Message}"); } } From fce6c525c24ab4493432c20d4a783dc1d2f53870 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:22:22 +0000 Subject: [PATCH 4/5] Add FileWatcher tests and fix pre-existing test namespace issue Co-authored-by: Xenthio <28588188+Xenthio@users.noreply.github.com> --- tests/Sandbox.UI.Tests/FileWatcherTests.cs | 85 ++++++++++++++++++++++ tests/Sandbox.UI.Tests/StyleSheetTests.cs | 1 + 2 files changed, 86 insertions(+) create mode 100644 tests/Sandbox.UI.Tests/FileWatcherTests.cs diff --git a/tests/Sandbox.UI.Tests/FileWatcherTests.cs b/tests/Sandbox.UI.Tests/FileWatcherTests.cs new file mode 100644 index 00000000..46a12ff2 --- /dev/null +++ b/tests/Sandbox.UI.Tests/FileWatcherTests.cs @@ -0,0 +1,85 @@ +using Sandbox.UI; +using Xunit; + +namespace Sandbox.UI.Tests; + +/// +/// Tests for the FileWatcher class used for stylesheet/Razor hotloading. +/// Note: FileWatcher is only active in DEBUG builds. +/// +public class FileWatcherTests +{ +#if DEBUG + [Fact] + public void FileWatcher_AddFile_RegistersFileForWatching() + { + // Arrange + var watcher = new FileWatcher(); + var testPath = "/tmp/test.scss"; + + // Act + watcher.AddFile(testPath); + + // Assert - watcher should be created without error + Assert.True(watcher.Enabled); + + // Cleanup + watcher.Dispose(); + } + + [Fact] + public void FileWatcher_Dispose_CleansUpResources() + { + // Arrange + var watcher = new FileWatcher(); + watcher.AddFile("/tmp/test.scss"); + + // Act + watcher.Dispose(); + + // Assert - no exceptions thrown during dispose + Assert.True(true); + } + + [Fact] + public void FileWatcher_Tick_ProcessesWithoutError() + { + // Arrange - just ensure Tick doesn't throw + + // Act + FileWatcher.Tick(0.016f); // ~60fps frame time + + // Assert - no exceptions + Assert.True(true); + } + + [Fact] + public void FileWatcher_DisposeAll_CleansUpAllWatchers() + { + // Arrange + var watcher1 = new FileWatcher(); + var watcher2 = new FileWatcher(); + watcher1.AddFile("/tmp/test1.scss"); + watcher2.AddFile("/tmp/test2.scss"); + + // Act + FileWatcher.DisposeAll(); + + // Assert - no exceptions + Assert.True(true); + } +#else + [Fact] + public void FileWatcher_InReleaseBuild_IsNoOp() + { + // In release builds, FileWatcher methods should be no-ops + var watcher = new FileWatcher(); + watcher.AddFile("/tmp/test.scss"); + + // In release, Enabled should be false (no-op mode) + Assert.False(watcher.Enabled); + + watcher.Dispose(); + } +#endif +} diff --git a/tests/Sandbox.UI.Tests/StyleSheetTests.cs b/tests/Sandbox.UI.Tests/StyleSheetTests.cs index 904048a6..c905c23f 100644 --- a/tests/Sandbox.UI.Tests/StyleSheetTests.cs +++ b/tests/Sandbox.UI.Tests/StyleSheetTests.cs @@ -1,4 +1,5 @@ using Sandbox.UI; +using Fazor.Controls; using System.Reflection; using Xunit; From 1a20a340cbbf80622713abba1fb077ddcd8d666e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 11 Jan 2026 12:25:18 +0000 Subject: [PATCH 5/5] Replace Console.WriteLine with Log.Info for consistency Co-authored-by: Xenthio <28588188+Xenthio@users.noreply.github.com> --- src/Fazor.UI/FazorApplication.cs | 2 +- src/Sandbox.UI/Styles/StyleSheet.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Fazor.UI/FazorApplication.cs b/src/Fazor.UI/FazorApplication.cs index f577c084..8e894c67 100644 --- a/src/Fazor.UI/FazorApplication.cs +++ b/src/Fazor.UI/FazorApplication.cs @@ -249,7 +249,7 @@ public static void Run(RootPanel rootPanel, int width = 1280, int height = 720, // Set up hotloading: when stylesheets change, dirty all styles on the root panel StyleSheet.OnStyleSheetsHotloaded += () => { - Console.WriteLine("[Hotload] Stylesheets changed - refreshing all panel styles"); + Sandbox.UI.Log.Info("[Hotload] Stylesheets changed - refreshing all panel styles"); rootPanel.DirtyAllStyles(); }; #endif diff --git a/src/Sandbox.UI/Styles/StyleSheet.cs b/src/Sandbox.UI/Styles/StyleSheet.cs index 37cac413..96232d74 100644 --- a/src/Sandbox.UI/Styles/StyleSheet.cs +++ b/src/Sandbox.UI/Styles/StyleSheet.cs @@ -264,7 +264,7 @@ private void AddWatcher( string name ) Watcher.OnChanges += ( watcher ) => { - Console.WriteLine( $"[StyleSheet] Hotloading: {watchFileName}" ); + Log.Info( $"[StyleSheet] Hotloading: {watchFileName}" ); UpdateFromFile( watchFileName, true ); // Notify the UI system that stylesheets have changed