Skip to content

Commit fc26efd

Browse files
authored
Added hotreload support for by-reference projects (#2309)
* Added hotreload support for by-reference projects * First pass at text scale * More fixes * Added font scale support
1 parent d776c90 commit fc26efd

File tree

15 files changed

+397
-75
lines changed

15 files changed

+397
-75
lines changed

Gum/Controls/TitleFilePathDisplay.xaml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,8 @@
2020
ToolTipService.InitialShowDelay="100">
2121
<TextBlock.ContextMenu>
2222
<ContextMenu>
23-
<MenuItem
24-
Click="OnViewInExplorerClick"
25-
Header="View in explorer" />
23+
<MenuItem Click="OnViewInExplorerClick" Header="View in explorer" />
24+
<MenuItem Click="OnCopyFullPathClick" Header="Copy full path" />
2625
</ContextMenu>
2726
</TextBlock.ContextMenu>
2827
</TextBlock>

Gum/Controls/TitleFilePathDisplay.xaml.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@ private static void OnFullPathChanged(DependencyObject d, DependencyPropertyChan
5050
}
5151
}
5252

53+
private void OnCopyFullPathClick(object sender, RoutedEventArgs e)
54+
{
55+
if (!string.IsNullOrEmpty(FullPath))
56+
{
57+
try
58+
{
59+
Clipboard.SetText(FullPath);
60+
}
61+
catch
62+
{
63+
// Silently fail if clipboard is unavailable
64+
}
65+
}
66+
}
67+
5368
private void OnViewInExplorerClick(object sender, RoutedEventArgs e)
5469
{
5570
if (!string.IsNullOrEmpty(FullPath) && File.Exists(FullPath))

Gum/GumFormsPlugin/MainGumFormsPlugin.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ private bool GetIfProjectHasForms()
9595
item.Extension != "gutx" &&
9696
item.Extension != "fnt" &&
9797
item.Extension != "bmfc" &&
98+
item.Extension != "setj" &&
99+
item.Extension != "json" &&
98100
item.Exists());
99101

100102
return firstMatch != null;

Gum/GumFormsPlugin/Services/FormsFileService.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public Dictionary<string, FilePath> GetSourceDestinations(bool isIncludeDemoScre
6363

6464
// Skip files that are not content or not relevant to import
6565
if (extension is "gumx" or "gumfcs" or
66-
"ganx" or "codsj" or "bmfc" or "fnt" or "exe")
66+
"ganx" or "codsj" or "bmfc" or "fnt" or "exe" or "setj" or "json")
6767
{
6868
continue;
6969
}

Gum/Plugins/InternalPlugins/ProjectPropertiesWindowPlugin/ProjectPropertiesViewModel.cs

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,6 @@ public int CanvasHeight
6969
set => Set(value);
7070
}
7171

72-
public decimal DisplayDensity
73-
{
74-
get => Get<decimal>();
75-
set => Set(value);
76-
}
7772

7873
public bool RestrictFileNamesForAndroid
7974
{

Gum/Themes/Frb.Styles.Defaults.xaml

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1016,9 +1016,7 @@
10161016

10171017
<!-- SubmenuItem: leaf items inside a dropdown (shared by Menu and ContextMenu) -->
10181018
<ControlTemplate x:Key="{x:Static MenuItem.SubmenuItemTemplateKey}" TargetType="{x:Type MenuItem}">
1019-
<Border
1020-
Name="Border"
1021-
SnapsToDevicePixels="True">
1019+
<Border Name="Border" SnapsToDevicePixels="True">
10221020
<Grid Background="Transparent">
10231021
<Grid.ColumnDefinitions>
10241022
<ColumnDefinition Width="Auto" SharedSizeGroup="Icon" />
@@ -1087,9 +1085,7 @@
10871085

10881086
<!-- SubmenuHeader: items inside a dropdown that have their own sub-items (shared by Menu and ContextMenu) -->
10891087
<ControlTemplate x:Key="{x:Static MenuItem.SubmenuHeaderTemplateKey}" TargetType="{x:Type MenuItem}">
1090-
<Border
1091-
Name="Border"
1092-
SnapsToDevicePixels="True">
1088+
<Border Name="Border" SnapsToDevicePixels="True">
10931089
<Grid Background="Transparent">
10941090
<Grid.ColumnDefinitions>
10951091
<ColumnDefinition Width="Auto" SharedSizeGroup="Icon" />
@@ -1132,9 +1128,7 @@
11321128
IsOpen="{TemplateBinding IsSubmenuOpen}"
11331129
Placement="Right"
11341130
PopupAnimation="Slide">
1135-
<Border
1136-
Padding="0,0,6,6"
1137-
Background="Transparent">
1131+
<Border Padding="0,0,6,6" Background="Transparent">
11381132
<Grid>
11391133
<!-- Background + shadow -->
11401134
<Border
@@ -1198,9 +1192,7 @@
11981192
BorderBrush="Transparent"
11991193
BorderThickness="1,1,1,0"
12001194
SnapsToDevicePixels="True">
1201-
<ContentPresenter
1202-
ContentSource="Header"
1203-
RecognizesAccessKey="True" />
1195+
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True" />
12041196
</Border>
12051197
<Popup
12061198
Name="Popup"
@@ -1226,17 +1218,19 @@
12261218
</Border.Effect>
12271219
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Cycle" />
12281220
</Border>
1229-
<!-- Cutout: erases the top border under the header for a connected look.
1230-
The outer Grid sizes to the header width; the Rectangle is inset 1px
1231-
on each side so it doesn't eat into the green border corners. -->
1221+
<!--
1222+
Cutout: erases the top border under the header for a connected look.
1223+
The outer Grid sizes to the header width; the Rectangle is inset 1px
1224+
on each side so it doesn't eat into the green border corners.
1225+
-->
12321226
<Grid
12331227
Name="TopBorderPatchContainer"
12341228
Width="{Binding ElementName=Border, Path=ActualWidth}"
12351229
Height="1"
12361230
HorizontalAlignment="Left"
12371231
VerticalAlignment="Top"
1238-
Visibility="Collapsed"
1239-
SnapsToDevicePixels="True">
1232+
SnapsToDevicePixels="True"
1233+
Visibility="Collapsed">
12401234
<Rectangle
12411235
Name="TopBorderPatch"
12421236
Margin="1,0,1,0"
@@ -1274,9 +1268,7 @@
12741268
Padding="6,3,6,3"
12751269
Background="Transparent"
12761270
CornerRadius="3">
1277-
<ContentPresenter
1278-
ContentSource="Header"
1279-
RecognizesAccessKey="True" />
1271+
<ContentPresenter ContentSource="Header" RecognizesAccessKey="True" />
12801272
</Border>
12811273
<ControlTemplate.Triggers>
12821274
<Trigger Property="IsHighlighted" Value="True">

MonoGameGum.Tests/Runtimes/TextRuntimeTests.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,74 @@ public void GetStyledSubstrings_ShouldRespectOverlappingCodes_OfSameVariable()
237237

238238
#endregion
239239

240+
#region GlobalFontScale
241+
242+
[Fact]
243+
public void GlobalFontScale_ShouldAffectAbsoluteHeight_WhenRelativeToChildren()
244+
{
245+
TextRuntime sut = new();
246+
sut.HeightUnits = Gum.DataTypes.DimensionUnitType.RelativeToChildren;
247+
sut.Height = 0;
248+
sut.Text = "Hello";
249+
250+
var baseHeight = sut.GetAbsoluteHeight();
251+
baseHeight.ShouldBeGreaterThan(0);
252+
253+
GraphicalUiElement.GlobalFontScale = 2;
254+
sut.UpdateLayout();
255+
256+
sut.GetAbsoluteHeight().ShouldBe(baseHeight * 2);
257+
}
258+
259+
[Fact]
260+
public void GlobalFontScale_ShouldAffectAbsoluteWidth_WhenRelativeToChildren()
261+
{
262+
TextRuntime sut = new();
263+
sut.WidthUnits = Gum.DataTypes.DimensionUnitType.RelativeToChildren;
264+
sut.Width = 0;
265+
sut.Text = "Hello";
266+
267+
var baseWidth = sut.GetAbsoluteWidth();
268+
baseWidth.ShouldBeGreaterThan(0);
269+
270+
GraphicalUiElement.GlobalFontScale = 2;
271+
sut.UpdateLayout();
272+
273+
sut.GetAbsoluteWidth().ShouldBe(baseWidth * 2);
274+
}
275+
276+
[Fact]
277+
public void GlobalFontScale_WrappedTextHeight_ShouldDoubleWhenScaleIsTwo()
278+
{
279+
TextRuntime sut = new();
280+
sut.Text = "Hello";
281+
282+
var containedText = (Text)sut.RenderableComponent;
283+
var baseHeight = containedText.WrappedTextHeight;
284+
baseHeight.ShouldBeGreaterThan(0);
285+
286+
GraphicalUiElement.GlobalFontScale = 2;
287+
288+
containedText.WrappedTextHeight.ShouldBe(baseHeight * 2);
289+
}
290+
291+
[Fact]
292+
public void GlobalFontScale_WrappedTextWidth_ShouldDoubleWhenScaleIsTwo()
293+
{
294+
TextRuntime sut = new();
295+
sut.Text = "Hello";
296+
297+
var containedText = (Text)sut.RenderableComponent;
298+
var baseWidth = containedText.WrappedTextWidth;
299+
baseWidth.ShouldBeGreaterThan(0);
300+
301+
GraphicalUiElement.GlobalFontScale = 2;
302+
303+
containedText.WrappedTextWidth.ShouldBe(baseWidth * 2);
304+
}
305+
306+
#endregion
307+
240308
#region HasEvents
241309

242310
[Fact]

MonoGameGum/GumHotReloadManager.cs

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
using Gum.DataTypes;
2+
using Gum.Managers;
3+
using Gum.Wireframe;
4+
using GumRuntime;
5+
using RenderingLibrary;
6+
using System;
7+
using System.Collections.Generic;
8+
using System.IO;
9+
using System.Linq;
10+
11+
#if XNALIKE
12+
namespace MonoGameGum;
13+
#elif RAYLIB
14+
namespace RaylibGum;
15+
#endif
16+
17+
public interface IGumHotReloadManager
18+
{
19+
event Action? ReloadCompleted;
20+
void Start(string absoluteGumxSourcePath);
21+
void Stop();
22+
void Update(GraphicalUiElement root);
23+
}
24+
25+
public class GumHotReloadManager : IGumHotReloadManager
26+
{
27+
private string _projectSourcePath = "";
28+
private FileSystemWatcher? _watcher;
29+
private volatile bool _pendingReload;
30+
private DateTime _lastChangeTime;
31+
32+
public event Action? ReloadCompleted;
33+
34+
public void Start(string absoluteGumxSourcePath)
35+
{
36+
_projectSourcePath = absoluteGumxSourcePath;
37+
38+
var directory = Path.GetDirectoryName(absoluteGumxSourcePath)
39+
?? throw new ArgumentException("Cannot determine directory from path.", nameof(absoluteGumxSourcePath));
40+
41+
_watcher = new FileSystemWatcher(directory)
42+
{
43+
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName,
44+
IncludeSubdirectories = true,
45+
EnableRaisingEvents = true
46+
};
47+
48+
_watcher.Changed += HandleFileChange;
49+
_watcher.Created += HandleFileChange;
50+
_watcher.Renamed += (sender, e) => HandleFileChange(sender, e);
51+
}
52+
53+
public void Stop()
54+
{
55+
if (_watcher != null)
56+
{
57+
_watcher.EnableRaisingEvents = false;
58+
_watcher.Dispose();
59+
_watcher = null;
60+
}
61+
}
62+
63+
public void Update(GraphicalUiElement root)
64+
{
65+
if (_pendingReload && (DateTime.UtcNow - _lastChangeTime) >= TimeSpan.FromMilliseconds(200))
66+
{
67+
_pendingReload = false;
68+
PerformReload(root);
69+
}
70+
}
71+
72+
private void HandleFileChange(object sender, FileSystemEventArgs e)
73+
{
74+
var extension = Path.GetExtension(e.FullPath).ToLowerInvariant();
75+
if (extension == ".gumx" || extension == ".gucx" || extension == ".gusx" || extension == ".gutx")
76+
{
77+
_pendingReload = true;
78+
_lastChangeTime = DateTime.UtcNow;
79+
}
80+
}
81+
82+
private void PerformReload(GraphicalUiElement root)
83+
{
84+
GumProjectSave newProject = GumProjectSave.Load(_projectSourcePath);
85+
newProject.Initialize();
86+
ObjectFinder.Self.GumProjectSave = newProject;
87+
88+
var byName = new Dictionary<string, ElementSave>(StringComparer.OrdinalIgnoreCase);
89+
foreach (var element in newProject.AllElements)
90+
{
91+
byName[element.Name] = element;
92+
}
93+
94+
// Snapshot element names before modifying the collection
95+
var childElementNames = root.Children
96+
.Select(c => c.ElementSave?.Name)
97+
.ToList();
98+
99+
// Remove existing children
100+
foreach (var child in root.Children.ToList())
101+
{
102+
child.RemoveFromManagers();
103+
child.Parent = null;
104+
}
105+
106+
// Recreate each child from the updated ElementSave, preserving order
107+
foreach (var name in childElementNames)
108+
{
109+
if (name != null && byName.TryGetValue(name, out var newEs))
110+
{
111+
var newChild = newEs.ToGraphicalUiElement(ISystemManagers.Default, addToManagers: false);
112+
root.Children.Add(newChild);
113+
}
114+
}
115+
116+
ReloadCompleted?.Invoke();
117+
}
118+
}

MonoGameGum/GumService.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ public SystemManagers SystemManagers
8383

8484
public DeferredActionQueue DeferredQueue { get; private set; }
8585

86+
private IGumHotReloadManager? _hotReloadManager;
87+
8688
/// <summary>
8789
/// Gets or sets the width of the canvas, which acts as the root-most coordiante space. This value
8890
/// represents the "internal coordinates" which can be adjusted by Camera zoom.
@@ -111,6 +113,20 @@ public float CanvasHeight
111113
/// <inheritdoc/>
112114
public InteractiveGue ModalRoot => FrameworkElement.ModalRoot;
113115

116+
/// <summary>
117+
/// Starts watching the Gum project source files at the given path.
118+
/// When any .gumx, .gucx, .gusx, or .gutx file changes, the project
119+
/// is reloaded and active elements in Root have their state reapplied.
120+
/// </summary>
121+
/// <param name="absoluteGumxSourcePath">
122+
/// Absolute path to the source .gumx file (not the bin/Content copy).
123+
/// </param>
124+
public void EnableHotReload(string absoluteGumxSourcePath)
125+
{
126+
_hotReloadManager = new GumHotReloadManager();
127+
_hotReloadManager.Start(absoluteGumxSourcePath);
128+
}
129+
114130
public void UseKeyboardDefaults()
115131
{
116132
Gum.Forms.Controls.FrameworkElement.KeyboardsForUiControl.Add(GumService.Default.Keyboard);
@@ -447,6 +463,7 @@ public void Update(GameTime gameTime, IEnumerable<GraphicalUiElement> roots)
447463
#endif
448464

449465
DeferredQueue.ProcessPending();
466+
_hotReloadManager?.Update(Root);
450467
GameTime = gameTime;
451468
#if XNALIKE
452469
FormsUtilities.Update(_game, gameTime, roots);

0 commit comments

Comments
 (0)