├── .github └── workflows │ ├── pre-release.yml │ └── release.yml ├── .gitignore ├── Binding ├── Binding.cs ├── BindingConfiguration.cs ├── BindingContext.cs ├── Collections │ └── ListItem.cs ├── ControlBinders │ ├── CheckBoxControlBinder.cs │ ├── ControlBinder.cs │ ├── GenericControlBinder.cs │ ├── ItemListControlBinder.cs │ ├── LineEditControlBinder.cs │ ├── OptionButtonControlBinder.cs │ ├── RangeControlBinder.cs │ └── TextEditControlBinder.cs ├── EventHandlers │ └── PropertyChangedEventHandler.cs ├── Exceptions │ └── ValidationException.cs ├── Extensions │ └── NodeExtensions.cs ├── Factories │ └── BindingBuilder.cs ├── Formatters │ ├── ISceneFormatter.cs │ ├── IValueFormatter.cs │ ├── ReverseBoolValueFormatter.cs │ └── SceneFormatter.cs ├── Interfaces │ ├── IControlBinder.cs │ ├── IListControlBinder.cs │ ├── IObservableObject.cs │ └── IViewModel.cs ├── ObservableObject.cs ├── Services │ ├── BackReferenceFactory.cs │ ├── ControlBinderProvider.cs │ ├── NodeChildCache.cs │ └── ReflectionService.cs ├── Utilities │ ├── BoundPropertySetter.cs │ ├── ListBindingHelpers.cs │ ├── PropertyTypeConverter.cs │ └── SceneInstancer.cs ├── ViewModel.cs └── WeakBackReference.cs ├── Godot.Community.ControlBinding.csproj ├── Godot.Community.ControlBinding.sln ├── LICENSE ├── README.md └── examples └── basic-bindings ├── .gitignore ├── Control.cs ├── Control.gd ├── Control.tscn ├── ControlBinding.Example.csproj ├── ControlBinding.Example.sln ├── CustomControl.cs ├── CustomControl.tscn ├── Player.cs ├── PlayerDataListFormatter.cs ├── PlayerDataListItem.cs ├── PlayerDataListItem.tscn ├── StringToListFormatter.cs ├── TestStringFormatter.cs ├── assets ├── icon_invalid-input.png ├── icon_invalid-input.png.import ├── icon_invalid-input_24.png └── icon_invalid-input_24.png.import ├── default_env.tres ├── icon.png ├── icon.png.import └── project.godot /.github/workflows/pre-release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v[0-9]+.[0-9]+.[0-9]+-preview.[0-9]" 5 | - "v[0-9]+.[0-9]+.[0-9]+-alpha.[0-9]" 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | timeout-minutes: 15 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v3 13 | - name: Set VERSION variable from tag 14 | run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV 15 | - name: Build 16 | run: dotnet build --configuration ExportRelease /p:Version=${VERSION} 17 | - name: Test 18 | run: dotnet test --configuration ExportRelease /p:Version=${VERSION} --no-build 19 | - name: Pack 20 | run: dotnet pack --configuration ExportRelease /p:Version=${VERSION} --no-build --output . 21 | - name: Push 22 | run: dotnet nuget push Godot.Community.ControlBinding.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_KEY} 23 | env: 24 | NUGET_KEY: ${{ secrets.NUGET_KEY }} 25 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v[0-9]+.[0-9]+.[0-9]+" 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | timeout-minutes: 15 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | - name: Verify commit exists in origin/main 13 | run: | 14 | git fetch --no-tags --prune --depth=1 origin +refs/heads/*:refs/remotes/origin/* 15 | git branch --remote --contains | grep origin/main 16 | - name: Set VERSION variable from tag 17 | run: echo "VERSION=${GITHUB_REF/refs\/tags\/v/}" >> $GITHUB_ENV 18 | - name: Build 19 | run: dotnet build --configuration ExportRelease /p:Version=${VERSION} 20 | - name: Test 21 | run: dotnet test --configuration ExportRelease /p:Version=${VERSION} --no-build 22 | - name: Pack 23 | run: dotnet pack --configuration ExportRelease /p:Version=${VERSION} --no-build --output . 24 | - name: Push 25 | run: dotnet nuget push Godot.Community.ControlBinding.${VERSION}.nupkg --source https://api.nuget.org/v3/index.json --api-key ${NUGET_KEY} 26 | env: 27 | NUGET_KEY: ${{ secrets.NUGET_KEY }} 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .godot 2 | .mono 3 | .vscode 4 | 5 | ## Ignore Visual Studio temporary files, build results, and 6 | ## files generated by popular Visual Studio add-ons. 7 | ## 8 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 9 | 10 | # User-specific files 11 | *.rsuser 12 | *.suo 13 | *.user 14 | *.userosscache 15 | *.sln.docstates 16 | 17 | # User-specific files (MonoDevelop/Xamarin Studio) 18 | *.userprefs 19 | 20 | # Mono auto generated files 21 | mono_crash.* 22 | 23 | # Build results 24 | [Dd]ebug/ 25 | [Dd]ebugPublic/ 26 | [Rr]elease/ 27 | [Rr]eleases/ 28 | x64/ 29 | x86/ 30 | [Ww][Ii][Nn]32/ 31 | [Aa][Rr][Mm]/ 32 | [Aa][Rr][Mm]64/ 33 | bld/ 34 | [Bb]in/ 35 | [Oo]bj/ 36 | [Ll]og/ 37 | [Ll]ogs/ 38 | 39 | # Visual Studio 2015/2017 cache/options directory 40 | .vs/ 41 | # Uncomment if you have tasks that create the project's static files in wwwroot 42 | #wwwroot/ 43 | 44 | # Visual Studio 2017 auto generated files 45 | Generated\ Files/ 46 | 47 | # MSTest test Results 48 | [Tt]est[Rr]esult*/ 49 | [Bb]uild[Ll]og.* 50 | 51 | # NUnit 52 | *.VisualState.xml 53 | TestResult.xml 54 | nunit-*.xml 55 | 56 | # Build Results of an ATL Project 57 | [Dd]ebugPS/ 58 | [Rr]eleasePS/ 59 | dlldata.c 60 | 61 | # Benchmark Results 62 | BenchmarkDotNet.Artifacts/ 63 | 64 | # .NET Core 65 | project.lock.json 66 | project.fragment.lock.json 67 | artifacts/ 68 | 69 | # ASP.NET Scaffolding 70 | ScaffoldingReadMe.txt 71 | 72 | # StyleCop 73 | StyleCopReport.xml 74 | 75 | # Files built by Visual Studio 76 | *_i.c 77 | *_p.c 78 | *_h.h 79 | *.ilk 80 | *.meta 81 | *.obj 82 | *.iobj 83 | *.pch 84 | *.pdb 85 | *.ipdb 86 | *.pgc 87 | *.pgd 88 | *.rsp 89 | *.sbr 90 | *.tlb 91 | *.tli 92 | *.tlh 93 | *.tmp 94 | *.tmp_proj 95 | *_wpftmp.csproj 96 | *.log 97 | *.tlog 98 | *.vspscc 99 | *.vssscc 100 | .builds 101 | *.pidb 102 | *.svclog 103 | *.scc 104 | 105 | # Chutzpah Test files 106 | _Chutzpah* 107 | 108 | # Visual C++ cache files 109 | ipch/ 110 | *.aps 111 | *.ncb 112 | *.opendb 113 | *.opensdf 114 | *.sdf 115 | *.cachefile 116 | *.VC.db 117 | *.VC.VC.opendb 118 | 119 | # Visual Studio profiler 120 | *.psess 121 | *.vsp 122 | *.vspx 123 | *.sap 124 | 125 | # Visual Studio Trace Files 126 | *.e2e 127 | 128 | # TFS 2012 Local Workspace 129 | $tf/ 130 | 131 | # Guidance Automation Toolkit 132 | *.gpState 133 | 134 | # ReSharper is a .NET coding add-in 135 | _ReSharper*/ 136 | *.[Rr]e[Ss]harper 137 | *.DotSettings.user 138 | 139 | # TeamCity is a build add-in 140 | _TeamCity* 141 | 142 | # DotCover is a Code Coverage Tool 143 | *.dotCover 144 | 145 | # AxoCover is a Code Coverage Tool 146 | .axoCover/* 147 | !.axoCover/settings.json 148 | 149 | # Coverlet is a free, cross platform Code Coverage Tool 150 | coverage*.json 151 | coverage*.xml 152 | coverage*.info 153 | 154 | # Visual Studio code coverage results 155 | *.coverage 156 | *.coveragexml 157 | 158 | # NCrunch 159 | _NCrunch_* 160 | .*crunch*.local.xml 161 | nCrunchTemp_* 162 | 163 | # MightyMoose 164 | *.mm.* 165 | AutoTest.Net/ 166 | 167 | # Web workbench (sass) 168 | .sass-cache/ 169 | 170 | # Installshield output folder 171 | [Ee]xpress/ 172 | 173 | # DocProject is a documentation generator add-in 174 | DocProject/buildhelp/ 175 | DocProject/Help/*.HxT 176 | DocProject/Help/*.HxC 177 | DocProject/Help/*.hhc 178 | DocProject/Help/*.hhk 179 | DocProject/Help/*.hhp 180 | DocProject/Help/Html2 181 | DocProject/Help/html 182 | 183 | # Click-Once directory 184 | publish/ 185 | 186 | # Publish Web Output 187 | *.[Pp]ublish.xml 188 | *.azurePubxml 189 | # Note: Comment the next line if you want to checkin your web deploy settings, 190 | # but database connection strings (with potential passwords) will be unencrypted 191 | *.pubxml 192 | *.publishproj 193 | 194 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 195 | # checkin your Azure Web App publish settings, but sensitive information contained 196 | # in these scripts will be unencrypted 197 | PublishScripts/ 198 | 199 | # NuGet Packages 200 | *.nupkg 201 | # NuGet Symbol Packages 202 | *.snupkg 203 | # The packages folder can be ignored because of Package Restore 204 | **/[Pp]ackages/* 205 | # except build/, which is used as an MSBuild target. 206 | !**/[Pp]ackages/build/ 207 | # Uncomment if necessary however generally it will be regenerated when needed 208 | #!**/[Pp]ackages/repositories.config 209 | # NuGet v3's project.json files produces more ignorable files 210 | *.nuget.props 211 | *.nuget.targets 212 | 213 | # Microsoft Azure Build Output 214 | csx/ 215 | *.build.csdef 216 | 217 | # Microsoft Azure Emulator 218 | ecf/ 219 | rcf/ 220 | 221 | # Windows Store app package directories and files 222 | AppPackages/ 223 | BundleArtifacts/ 224 | Package.StoreAssociation.xml 225 | _pkginfo.txt 226 | *.appx 227 | *.appxbundle 228 | *.appxupload 229 | 230 | # Visual Studio cache files 231 | # files ending in .cache can be ignored 232 | *.[Cc]ache 233 | # but keep track of directories ending in .cache 234 | !?*.[Cc]ache/ 235 | 236 | # Others 237 | ClientBin/ 238 | ~$* 239 | *~ 240 | *.dbmdl 241 | *.dbproj.schemaview 242 | *.jfm 243 | *.pfx 244 | *.publishsettings 245 | orleans.codegen.cs 246 | 247 | # Including strong name files can present a security risk 248 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 249 | #*.snk 250 | 251 | # Since there are multiple workflows, uncomment next line to ignore bower_components 252 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 253 | #bower_components/ 254 | 255 | # RIA/Silverlight projects 256 | Generated_Code/ 257 | 258 | # Backup & report files from converting an old project file 259 | # to a newer Visual Studio version. Backup files are not needed, 260 | # because we have git ;-) 261 | _UpgradeReport_Files/ 262 | Backup*/ 263 | UpgradeLog*.XML 264 | UpgradeLog*.htm 265 | ServiceFabricBackup/ 266 | *.rptproj.bak 267 | 268 | # SQL Server files 269 | *.mdf 270 | *.ldf 271 | *.ndf 272 | 273 | # Business Intelligence projects 274 | *.rdl.data 275 | *.bim.layout 276 | *.bim_*.settings 277 | *.rptproj.rsuser 278 | *- [Bb]ackup.rdl 279 | *- [Bb]ackup ([0-9]).rdl 280 | *- [Bb]ackup ([0-9][0-9]).rdl 281 | 282 | # Microsoft Fakes 283 | FakesAssemblies/ 284 | 285 | # GhostDoc plugin setting file 286 | *.GhostDoc.xml 287 | 288 | # Node.js Tools for Visual Studio 289 | .ntvs_analysis.dat 290 | node_modules/ 291 | 292 | # Visual Studio 6 build log 293 | *.plg 294 | 295 | # Visual Studio 6 workspace options file 296 | *.opt 297 | 298 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 299 | *.vbw 300 | 301 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 302 | *.vbp 303 | 304 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 305 | *.dsw 306 | *.dsp 307 | 308 | # Visual Studio 6 technical files 309 | *.ncb 310 | *.aps 311 | 312 | # Visual Studio LightSwitch build output 313 | **/*.HTMLClient/GeneratedArtifacts 314 | **/*.DesktopClient/GeneratedArtifacts 315 | **/*.DesktopClient/ModelManifest.xml 316 | **/*.Server/GeneratedArtifacts 317 | **/*.Server/ModelManifest.xml 318 | _Pvt_Extensions 319 | 320 | # Paket dependency manager 321 | .paket/paket.exe 322 | paket-files/ 323 | 324 | # FAKE - F# Make 325 | .fake/ 326 | 327 | # CodeRush personal settings 328 | .cr/personal 329 | 330 | # Python Tools for Visual Studio (PTVS) 331 | __pycache__/ 332 | *.pyc 333 | 334 | # Cake - Uncomment if you are using it 335 | # tools/** 336 | # !tools/packages.config 337 | 338 | # Tabs Studio 339 | *.tss 340 | 341 | # Telerik's JustMock configuration file 342 | *.jmconfig 343 | 344 | # BizTalk build output 345 | *.btp.cs 346 | *.btm.cs 347 | *.odx.cs 348 | *.xsd.cs 349 | 350 | # OpenCover UI analysis results 351 | OpenCover/ 352 | 353 | # Azure Stream Analytics local run output 354 | ASALocalRun/ 355 | 356 | # MSBuild Binary and Structured Log 357 | *.binlog 358 | 359 | # NVidia Nsight GPU debugger configuration file 360 | *.nvuser 361 | 362 | # MFractors (Xamarin productivity tool) working folder 363 | .mfractor/ 364 | 365 | # Local History for Visual Studio 366 | .localhistory/ 367 | 368 | # Visual Studio History (VSHistory) files 369 | .vshistory/ 370 | 371 | # BeatPulse healthcheck temp database 372 | healthchecksdb 373 | 374 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 375 | MigrationBackup/ 376 | 377 | # Ionide (cross platform F# VS Code tools) working folder 378 | .ionide/ 379 | 380 | # Fody - auto-generated XML schema 381 | FodyWeavers.xsd 382 | 383 | # VS Code files for those working on multiple tools 384 | .vscode/* 385 | !.vscode/settings.json 386 | !.vscode/tasks.json 387 | !.vscode/launch.json 388 | !.vscode/extensions.json 389 | *.code-workspace 390 | 391 | # Local History for Visual Studio Code 392 | .history/ 393 | 394 | # Windows Installer files from build outputs 395 | *.cab 396 | *.msi 397 | *.msix 398 | *.msm 399 | *.msp 400 | 401 | # JetBrains Rider 402 | *.sln.iml -------------------------------------------------------------------------------- /Binding/Binding.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.ControlBinders; 2 | using Godot.Community.ControlBinding.Interfaces; 3 | using Godot.Community.ControlBinding.Services; 4 | using Godot.Community.ControlBinding.Utilities; 5 | using System; 6 | using System.Collections; 7 | using System.Collections.Specialized; 8 | using System.ComponentModel; 9 | using System.Linq; 10 | 11 | namespace Godot.Community.ControlBinding 12 | { 13 | public enum BindingStatus 14 | { 15 | Inactive, 16 | Active, 17 | Invalid 18 | } 19 | 20 | internal class Binding 21 | { 22 | public event EventHandler BindingStatusChanged; 23 | 24 | public delegate void ValidationFailedEventHandler(Godot.Control control, string propertyName, string message); 25 | public event ValidationFailedEventHandler ValidationFailed; 26 | 27 | public delegate void ValidationSuceededEventHandler(Godot.Control control, string propertyName); 28 | public event ValidationSuceededEventHandler ValidationSucceeded; 29 | 30 | private BindingConfiguration _bindingConfiguration; 31 | public BindingConfiguration BindingConfiguration 32 | { 33 | get => _bindingConfiguration; 34 | private set => _bindingConfiguration = value; 35 | } 36 | 37 | private readonly IControlBinder _controlBinder; 38 | public readonly BoundPropertySetter BoundPropertySetter; 39 | 40 | public Binding(BindingConfiguration bindingConfiguration, 41 | IControlBinder controlBinder) 42 | { 43 | BindingConfiguration = bindingConfiguration; 44 | _controlBinder = controlBinder; 45 | BoundPropertySetter = new BoundPropertySetter(bindingConfiguration.Formatter, bindingConfiguration.Validators); 46 | } 47 | 48 | public BindingStatus BindingStatus { get; set; } 49 | 50 | public void BindControl() 51 | { 52 | if (!_controlBinder.IsBound) 53 | _controlBinder.BindControl(BindingConfiguration); 54 | 55 | if (BindingStatus != BindingStatus.Invalid) 56 | { 57 | if (!_bindingConfiguration.BoundControl.IsAlive) 58 | { 59 | BindingStatus = BindingStatus.Invalid; 60 | return; 61 | } 62 | 63 | ResolveBindingPath(); 64 | SubscribeChangeEvents(); 65 | SetInitialValue(); 66 | } 67 | } 68 | 69 | public void UnbindControl() 70 | { 71 | if (BindingConfiguration.TargetObject.Target is IObservableObject observable) 72 | { 73 | observable.PropertyChanged -= OnPropertyChanged; 74 | } 75 | 76 | if (BindingConfiguration.TargetObject.Target is INotifyCollectionChanged observable1 77 | && _controlBinder is IListControlBinder listControlBinder) 78 | { 79 | observable1.CollectionChanged -= listControlBinder.OnObservableListChanged; 80 | foreach (var item in observable1 as IList) 81 | { 82 | if (item is IObservableObject oItem) 83 | { 84 | oItem.PropertyChanged -= OnListItemChanged; 85 | } 86 | } 87 | } 88 | 89 | foreach (var backReference in BindingConfiguration.BackReferences) 90 | { 91 | if (backReference.ObjectReference.Target is IObservableObject observableObject) 92 | { 93 | observableObject.PropertyChanged -= OnBackReferenceChanged; 94 | } 95 | } 96 | 97 | if (BindingConfiguration.BoundControl.IsAlive) 98 | { 99 | (_controlBinder as ControlBinder).ControlValueChanged -= OnSourcePropertyChanged; 100 | if (BindingConfiguration.BoundControl.Target is IObservableObject observable2) 101 | { 102 | observable2.PropertyChanged -= OnSourcePropertyChanged; 103 | } 104 | } 105 | 106 | BindingConfiguration.BackReferences.Clear(); 107 | } 108 | 109 | private void ResolveBindingPath() 110 | { 111 | var pathNodes = BindingConfiguration.Path?.Split('.'); 112 | var targetPropertyName = pathNodes.Last(); 113 | 114 | var pathObjects = BackReferenceFactory.GetPathObjectsAndBuildBackReferences(pathNodes.ToList(), ref _bindingConfiguration); 115 | 116 | var targetObject = pathObjects.Last(); 117 | 118 | if (targetObject is not null && targetObject is not IObservableObject && targetObject is not INotifyCollectionChanged) 119 | { 120 | GD.PrintErr($"ControlBinding: Binding from node {targetObject} on path {BindingConfiguration.Path} will not update with changes. Node is not of type ObservableObject"); 121 | } 122 | 123 | BindingConfiguration.TargetObject = new WeakReference(pathObjects.Last()); 124 | BindingConfiguration.TargetPropertyName = targetPropertyName; 125 | } 126 | 127 | private void SubscribeChangeEvents() 128 | { 129 | if (BindingConfiguration.BoundControl.Target is not Godot.Control) 130 | { 131 | BindingStatus = BindingStatus.Invalid; 132 | return; 133 | } 134 | 135 | if (BindingStatus != BindingStatus.Active && BindingConfiguration.BoundControl.Target is Node node) 136 | { 137 | node.TreeExiting += OnBoundControlTreeExiting; 138 | } 139 | 140 | if (BindingConfiguration.TargetObject.Target is IObservableObject observable) 141 | { 142 | observable.PropertyChanged += OnPropertyChanged; 143 | } 144 | 145 | if (BindingConfiguration.TargetObject.Target is INotifyCollectionChanged observable1) 146 | { 147 | observable1.CollectionChanged += OnObservableListChanged; 148 | } 149 | 150 | // Register for changes to back references to trigger rebinding 151 | foreach (var backReference in BindingConfiguration.BackReferences.Select(x => x.ObjectReference.Target)) 152 | { 153 | if (backReference != BindingConfiguration.TargetObject.Target && 154 | backReference is IObservableObject observable3) 155 | { 156 | observable3.PropertyChanged += OnBackReferenceChanged; 157 | } 158 | } 159 | 160 | if (BindingConfiguration.BoundControl.IsAlive) 161 | { 162 | (_controlBinder as ControlBinder).ControlValueChanged += OnSourcePropertyChanged; 163 | if (BindingConfiguration.BoundControl.Target is IObservableObject observable2) 164 | { 165 | observable2.PropertyChanged += OnSourcePropertyChanged; 166 | } 167 | } 168 | 169 | BindingStatus = BindingStatus.Active; 170 | } 171 | 172 | private void SetInitialValue() 173 | { 174 | if (BindingStatus != BindingStatus.Active) 175 | return; 176 | 177 | if (BindingConfiguration.BindingMode == BindingMode.OneWay || BindingConfiguration.BindingMode == BindingMode.TwoWay) 178 | { 179 | if (BindingConfiguration.IsListBinding) 180 | { 181 | SetInitialListValue(BindingConfiguration.TargetObject.Target); 182 | } 183 | else 184 | { 185 | var target = BindingConfiguration.TargetObject.Target; 186 | var source = BindingConfiguration.BoundControl.Target; 187 | 188 | BoundPropertySetter.SetBoundControlValue(target, 189 | BindingConfiguration.TargetPropertyName, 190 | source as Godot.Control, 191 | BindingConfiguration.BoundPropertyName); 192 | } 193 | } 194 | else 195 | { 196 | if (!BindingConfiguration.IsListBinding) 197 | { 198 | if (!BindingConfiguration.BoundControl.IsAlive) 199 | return; 200 | 201 | BoundPropertySetter.SetBoundPropertyValue(BindingConfiguration.BoundControl.Target as Godot.Control, 202 | BindingConfiguration.BoundPropertyName, 203 | BindingConfiguration.TargetObject.Target, 204 | BindingConfiguration.TargetPropertyName); 205 | } 206 | } 207 | } 208 | 209 | private void SetInitialListValue(object sender) 210 | { 211 | if (sender is INotifyCollectionChanged list) 212 | { 213 | OnObservableListChanged(sender, new(NotifyCollectionChangedAction.Reset)); 214 | OnObservableListChanged(sender, new(NotifyCollectionChangedAction.Add, list as IList, 0)); 215 | } 216 | } 217 | 218 | public virtual void OnPropertyChanged(object sender, PropertyChangedEventArgs e) 219 | { 220 | if ((BindingConfiguration.BindingMode == BindingMode.OneWay || BindingConfiguration.BindingMode == BindingMode.TwoWay) 221 | && e.PropertyName == BindingConfiguration.TargetPropertyName) 222 | { 223 | BoundPropertySetter.SetBoundControlValue(BindingConfiguration.TargetObject.Target, 224 | BindingConfiguration.TargetPropertyName, 225 | BindingConfiguration.BoundControl.Target as Godot.Control, 226 | BindingConfiguration.BoundPropertyName); 227 | } 228 | } 229 | 230 | public void OnBackReferenceChanged(object sender, PropertyChangedEventArgs e) 231 | { 232 | if (BindingConfiguration.BackReferences.Any(x => x.ObjectReference.Target == sender && x.PropertyName == e.PropertyName)) 233 | { 234 | UnbindControl(); 235 | BindControl(); 236 | } 237 | } 238 | 239 | private void OnBoundControlTreeExiting() 240 | { 241 | BindingStatus = BindingStatus.Invalid; 242 | BindingStatusChanged?.Invoke(this, BindingStatus); 243 | if (_bindingConfiguration.BoundControl.Target is Node node) 244 | { 245 | node.TreeExiting -= OnBoundControlTreeExiting; 246 | } 247 | UnbindControl(); 248 | } 249 | 250 | public void OnSourcePropertyChanged(object sender, PropertyChangedEventArgs e) 251 | { 252 | if (BindingConfiguration.TargetObject.Target == null) 253 | return; 254 | 255 | if (BindingConfiguration.BoundPropertyName != e.PropertyName) 256 | return; 257 | 258 | if (BindingConfiguration.BindingMode == BindingMode.TwoWay || BindingConfiguration.BindingMode == BindingMode.OneWayToTarget) 259 | { 260 | try 261 | { 262 | BoundPropertySetter.SetBoundPropertyValue(BindingConfiguration.BoundControl.Target as Godot.Control, 263 | BindingConfiguration.BoundPropertyName, 264 | BindingConfiguration.TargetObject.Target, 265 | BindingConfiguration.TargetPropertyName); 266 | 267 | OnPropertyValidationChanged(BindingConfiguration.BoundControl.Target as Godot.Control, BindingConfiguration.TargetPropertyName, true, null); 268 | } 269 | catch (ValidationException vex) 270 | { 271 | OnPropertyValidationChanged(BindingConfiguration.BoundControl.Target as Godot.Control, BindingConfiguration.TargetPropertyName, false, vex.Message); 272 | } 273 | } 274 | } 275 | 276 | private void OnPropertyValidationChanged(Godot.Control control, string propertyName, bool isValid, string message) 277 | { 278 | if (isValid) 279 | { 280 | ValidationSucceeded?.Invoke(control, propertyName); 281 | } 282 | else 283 | { 284 | ValidationFailed?.Invoke(control, propertyName, message); 285 | } 286 | 287 | BindingConfiguration.OnValidationChangedHandler?.Invoke(control, isValid, message); 288 | } 289 | 290 | public virtual void OnObservableListChanged(object sender, NotifyCollectionChangedEventArgs eventArgs) 291 | { 292 | if (_controlBinder is IListControlBinder listControlBinder) 293 | listControlBinder.OnObservableListChanged(sender, eventArgs); 294 | 295 | if (eventArgs.Action == NotifyCollectionChangedAction.Add) 296 | { 297 | foreach (var item in eventArgs.NewItems) 298 | { 299 | if (item is IObservableObject observableObject) 300 | { 301 | observableObject.PropertyChanged += OnListItemChanged; 302 | } 303 | } 304 | } 305 | 306 | if (eventArgs.Action == NotifyCollectionChangedAction.Remove) 307 | { 308 | foreach (var item in eventArgs.OldItems) 309 | { 310 | if (item is IObservableObject observableObject) 311 | { 312 | observableObject.PropertyChanged -= OnListItemChanged; 313 | } 314 | } 315 | } 316 | } 317 | 318 | private void OnListItemChanged(object sender, PropertyChangedEventArgs e) 319 | { 320 | if (_controlBinder is IListControlBinder listControlBinder) 321 | listControlBinder.OnListItemChanged(sender); 322 | } 323 | } 324 | } -------------------------------------------------------------------------------- /Binding/BindingConfiguration.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Formatters; 2 | using Godot.Community.ControlBinding.Interfaces; 3 | using System; 4 | using System.Collections.Generic; 5 | 6 | namespace Godot.Community.ControlBinding; 7 | 8 | public enum BindingMode 9 | { 10 | OneWay, 11 | TwoWay, 12 | OneWayToTarget, 13 | } 14 | 15 | internal class BindingConfiguration 16 | { 17 | public string BoundPropertyName { get; set; } 18 | public string TargetPropertyName { get; set; } 19 | public BindingMode BindingMode { get; set; } 20 | public bool IsListBinding { get; set; } 21 | public IObservableObject Owner { get; init; } 22 | public WeakReference BoundControl { get; set; } 23 | public WeakReference TargetObject { get; set; } 24 | public IValueFormatter Formatter { get; set; } 25 | public List BackReferences { get; set; } 26 | public ISceneFormatter SceneFormatter { get; set; } 27 | public List> Validators { get; set; } = new(); 28 | public string Path { get; set; } 29 | public Action OnValidationChangedHandler { get; set; } 30 | } -------------------------------------------------------------------------------- /Binding/BindingContext.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Collections; 2 | using Godot.Community.ControlBinding.ControlBinders; 3 | using Godot.Community.ControlBinding.Factories; 4 | using Godot.Community.ControlBinding.Formatters; 5 | using Godot.Community.ControlBinding.Interfaces; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Collections.ObjectModel; 9 | using System.Linq; 10 | 11 | namespace Godot.Community.ControlBinding 12 | { 13 | public class BindingContext : ObservableObject 14 | { 15 | private readonly IObservableObject _bindingRoot; 16 | public BindingContext(IObservableObject bindingRoot) 17 | { 18 | _bindingRoot = bindingRoot; 19 | } 20 | 21 | private readonly static Dictionary> _bindings = new(); 22 | 23 | private void AddBinding(Binding binding) 24 | { 25 | if (!_bindings.ContainsKey(binding.BindingConfiguration.BoundControl.Target)) 26 | { 27 | _bindings.Add(binding.BindingConfiguration.BoundControl.Target, new List()); 28 | } 29 | 30 | binding.ValidationFailed += OnPropertyValidationFailed; 31 | binding.ValidationSucceeded += OnPropertyValidationSucceeded; 32 | binding.BindingStatusChanged += OnBindingStatusChanged; 33 | 34 | _bindings[binding.BindingConfiguration.BoundControl.Target].Add(binding); 35 | } 36 | 37 | private void OnBindingStatusChanged(object sender, BindingStatus e) 38 | { 39 | if (sender is Binding binding && e == BindingStatus.Invalid) 40 | { 41 | _bindings[binding.BindingConfiguration.BoundControl.Target].Remove(binding); 42 | if (_bindings[binding.BindingConfiguration.BoundControl.Target].Count == 0) 43 | { 44 | _bindings.Remove(binding.BindingConfiguration.BoundControl.Target); 45 | } 46 | } 47 | } 48 | 49 | /// 50 | /// Bind a control property to an object property 51 | /// 52 | /// The node to bind to 53 | /// The property of the Godot control to bind to 54 | /// The path of the property to bind to. Relative to this object 55 | /// The binding mode to use 56 | /// The to use to format the Control property and target property 57 | public BindingBuilder BindProperty( 58 | Node node, 59 | string sourceProperty, 60 | string path, 61 | BindingMode bindingMode = BindingMode.OneWay, 62 | IValueFormatter formatter = null 63 | ) 64 | { 65 | if (ControlBinderProvider.GetBinder(node) is IControlBinder binder) 66 | { 67 | var bindingConfiguration = new BindingConfiguration 68 | { 69 | BindingMode = bindingMode, 70 | BoundPropertyName = sourceProperty, 71 | Path = path, 72 | BoundControl = new WeakReference(node), 73 | Formatter = formatter, 74 | Owner = _bindingRoot, 75 | }; 76 | 77 | var binding = new Binding(bindingConfiguration, binder); 78 | binding.BindControl(); 79 | AddBinding(binding); 80 | return new BindingBuilder(binding); 81 | } 82 | return null; 83 | } 84 | 85 | /// 86 | /// Bind a list control to an IObservableList or IList property 87 | /// Note: list controls include OptionButton and ItemList 88 | /// 89 | /// The path of the Godot control in the scene. 90 | /// The path of the property to bind to. Relative to this object. 91 | /// The binding mode to use 92 | /// The IValueFormatter to use to format the list item and target property. Return a for greater formatting control. 93 | public void BindListProperty( 94 | Node node, 95 | string path, 96 | BindingMode bindingMode = BindingMode.OneWay, 97 | IValueFormatter formatter = null 98 | ) 99 | { 100 | if (ControlBinderProvider.GetBinder(node) is IControlBinder binder) 101 | { 102 | var bindingConfiguration = new BindingConfiguration 103 | { 104 | BindingMode = bindingMode, 105 | BoundControl = new WeakReference(node), 106 | Formatter = formatter, 107 | IsListBinding = true, 108 | Owner = _bindingRoot, 109 | Path = path 110 | }; 111 | 112 | var binding = new Binding(bindingConfiguration, binder); 113 | binding.BindControl(); 114 | AddBinding(binding); 115 | } 116 | } 117 | 118 | public void BindSceneList( 119 | Node node, 120 | string path, 121 | string scenePath, 122 | BindingMode bindingMode = BindingMode.OneWay) 123 | { 124 | var binder = new GenericControlBinder(); 125 | var bindingConfiguration = new BindingConfiguration 126 | { 127 | BindingMode = bindingMode, 128 | BoundControl = new WeakReference(node), 129 | SceneFormatter = new SceneFormatter 130 | { 131 | ScenePath = scenePath 132 | }, 133 | IsListBinding = true, 134 | Owner = _bindingRoot, 135 | Path = path 136 | }; 137 | 138 | var binding = new Binding(bindingConfiguration, binder); 139 | binding.BindControl(); 140 | AddBinding(binding); 141 | } 142 | 143 | /// 144 | /// Binds an emum to an OptionButton control with optional path for the selected value 145 | /// 146 | /// The path of the Godot control in the scene. 147 | /// The path of the property to bind to. Relative to this object. 148 | /// The enum type to bind the OptionButton to 149 | public void BindEnumProperty(OptionButton node, string selectedItemPath = null) where T : Enum 150 | { 151 | ObservableCollection targetObject = new(); 152 | foreach (var entry in Enum.GetValues(typeof(T))) 153 | { 154 | targetObject.Add((T)entry); 155 | } 156 | 157 | // bind the list items (static list binding - enums won't change at runtime) 158 | if (ControlBinderProvider.GetBinder(node) is IControlBinder binder) 159 | { 160 | var bindingConfiguration = new BindingConfiguration 161 | { 162 | BindingMode = BindingMode.OneWay, 163 | TargetObject = new WeakReference(targetObject), 164 | BoundControl = new WeakReference(node), 165 | IsListBinding = true, 166 | Path = string.Empty, 167 | Owner = _bindingRoot, 168 | Formatter = new ValueFormatter 169 | { 170 | FormatControl = (v, _) => 171 | { 172 | var enumValue = (T)v; 173 | return new ListItem 174 | { 175 | DisplayValue = enumValue.ToString(), 176 | Id = (int)Enum.Parse(typeof(T), v.ToString()) 177 | }; 178 | } 179 | } 180 | }; 181 | 182 | var binding = new Binding(bindingConfiguration, binder); 183 | binding.BindControl(); 184 | AddBinding(binding); 185 | } 186 | if (!string.IsNullOrEmpty(selectedItemPath)) 187 | { 188 | BindProperty(node, "Selected", selectedItemPath, BindingMode.TwoWay, new ValueFormatter 189 | { 190 | FormatTarget = (v, _) => 191 | { 192 | if (v is null || targetObject is null) 193 | return null; 194 | 195 | return targetObject[(int)v == -1 ? 0 : (int)v]; 196 | }, 197 | FormatControl = (v, _) => 198 | { 199 | if (v is null || targetObject is null) 200 | return null; 201 | 202 | return targetObject.IndexOf((T)v); 203 | } 204 | }); 205 | } 206 | } 207 | 208 | private readonly Dictionary> _validationErrors = new(); 209 | public void OnPropertyValidationFailed(Control control, string targetPropertyName, string message) 210 | { 211 | var instanceId = control.GetInstanceId(); 212 | if (!_validationErrors.ContainsKey(instanceId)) 213 | { 214 | _validationErrors.Add(instanceId, new()); 215 | } 216 | 217 | _validationErrors[instanceId].Clear(); 218 | _validationErrors[instanceId].Add(message); 219 | 220 | HasErrors = true; 221 | ControlValidationChanged?.Invoke(control, targetPropertyName, message, false); 222 | } 223 | 224 | public void OnPropertyValidationSucceeded(Godot.Control control, string propertyName) 225 | { 226 | var instanceId = control.GetInstanceId(); 227 | if (_validationErrors.ContainsKey(instanceId)) 228 | { 229 | // raise validation changed 230 | _validationErrors.Remove(instanceId); 231 | ControlValidationChanged?.Invoke(control, propertyName, null, true); 232 | } 233 | 234 | if (!_validationErrors.Any() && HasErrors) 235 | HasErrors = false; 236 | } 237 | 238 | private bool _hasErrors; 239 | 240 | public bool HasErrors 241 | { 242 | get => _hasErrors; 243 | 244 | private set 245 | { 246 | _hasErrors = value; 247 | OnPropertyChanged(); 248 | } 249 | } 250 | 251 | public List GetValidationMessages() 252 | { 253 | return _validationErrors.SelectMany(x => x.Value).ToList(); 254 | } 255 | 256 | public event ValidationChangedEventHandler ControlValidationChanged; 257 | } 258 | } -------------------------------------------------------------------------------- /Binding/Collections/ListItem.cs: -------------------------------------------------------------------------------- 1 | namespace Godot.Community.ControlBinding.Collections; 2 | /// 3 | /// A ListItem can be used to format items in an ItemList control when bound to a list. 4 | /// Return a ListItem from an T:ControlBinding.Formatters.IValueFormatter to format the list item. 5 | /// 6 | public class ListItem 7 | { 8 | public string DisplayValue { get; set; } 9 | public int Id { get; set; } = -1; 10 | public Texture2D Icon { get; set; } 11 | 12 | public string ScenePath { get; set; } 13 | public string Tooltip { get; set; } 14 | public Variant Metadata { get; set; } 15 | public bool? Disabled { get; set; } 16 | 17 | // Background color is not stateful. If it is set it will not be automatically reset. 18 | // NOTE: This would be possible if a ListItem backing is stored in cache and re-referenced. 19 | // However, ItemList doesn't support getting the Godot default colors, only the custom colors. 20 | public Color? BackgroundColor { get; set; } 21 | 22 | // Background color is not stateful. If it is set it will not be automatically reset. 23 | public Color? ForegroundColor { get; set; } 24 | public string Language { get; set; } 25 | public Rect2? IconRegion { get; set; } 26 | public Color? IconModulate { get; set; } 27 | public bool? Selectable { get; set; } 28 | public Godot.Control.TextDirection TextDirection { get; set; } 29 | public bool? IconTransposed { get; set; } 30 | } 31 | -------------------------------------------------------------------------------- /Binding/ControlBinders/CheckBoxControlBinder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Specialized; 4 | 5 | namespace Godot.Community.ControlBinding.ControlBinders; 6 | internal partial class CheckBoxControlBinder : ControlBinder 7 | { 8 | private readonly List _allowedTwoWayBindingProperties = new(){ 9 | nameof(CheckBox.ButtonPressed) 10 | }; 11 | 12 | public override void BindControl(BindingConfiguration bindingConfiguration) 13 | { 14 | if (_allowedTwoWayBindingProperties.Contains(bindingConfiguration.BoundPropertyName) && 15 | (bindingConfiguration.BindingMode == BindingMode.TwoWay || bindingConfiguration.BindingMode == BindingMode.OneWayToTarget)) 16 | { 17 | CheckBox boundControl = bindingConfiguration.BoundControl.Target as CheckBox; 18 | if (bindingConfiguration.BoundPropertyName == nameof(CheckBox.ButtonPressed)) 19 | boundControl.Toggled += OnToggledChanged; 20 | } 21 | base.BindControl(bindingConfiguration); 22 | } 23 | 24 | public void OnToggledChanged(bool value) 25 | { 26 | OnControlValueChanged(_bindingConfiguration.BoundControl.Target as Godot.Control, "ButtonPressed"); 27 | } 28 | 29 | public override bool CanBindFor(object control) 30 | { 31 | return control is CheckBox; 32 | } 33 | 34 | public override IControlBinder CreateInstance() 35 | { 36 | return new CheckBoxControlBinder(); 37 | } 38 | } -------------------------------------------------------------------------------- /Binding/ControlBinders/ControlBinder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Specialized; 2 | using System.ComponentModel; 3 | 4 | namespace Godot.Community.ControlBinding.ControlBinders 5 | { 6 | internal abstract partial class ControlBinder : IControlBinder 7 | { 8 | public bool IsBound { get; set; } 9 | public int Priority => 1; 10 | internal BindingConfiguration _bindingConfiguration; 11 | 12 | public delegate void ControlValueChangedEventHandler(Godot.Control control, PropertyChangedEventArgs args); 13 | public event ControlValueChangedEventHandler ControlValueChanged; 14 | public void OnControlValueChanged(Godot.Control control, string propertyName) 15 | { 16 | ControlValueChanged?.Invoke(control, new PropertyChangedEventArgs(propertyName)); 17 | } 18 | 19 | public virtual void BindControl(BindingConfiguration bindingConfiguration) 20 | { 21 | _bindingConfiguration = bindingConfiguration; 22 | IsBound = true; 23 | } 24 | 25 | #region Abstract methods 26 | 27 | public abstract IControlBinder CreateInstance(); 28 | public abstract bool CanBindFor(object control); 29 | 30 | #endregion 31 | } 32 | } -------------------------------------------------------------------------------- /Binding/ControlBinders/GenericControlBinder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections; 2 | using System.Collections.Generic; 3 | using System.Collections.Specialized; 4 | using System.Linq; 5 | using Godot.Community.ControlBinding.Services; 6 | 7 | namespace Godot.Community.ControlBinding.ControlBinders; 8 | 9 | internal partial class GenericControlBinder : ControlBinder, IListControlBinder 10 | { 11 | public new static int Priority => 0; 12 | internal Godot.Control _boundControl; 13 | private readonly NodeChildCache _nodeChildCache = new(); 14 | 15 | public event IListControlBinder.ControlChildListChangedEventHandler ControlChildListChanged; 16 | 17 | public override void BindControl(BindingConfiguration bindingConfiguration) 18 | { 19 | if (bindingConfiguration.BoundControl.Target is Godot.Control controlInstance) 20 | _boundControl = controlInstance; 21 | 22 | if (bindingConfiguration.IsListBinding && bindingConfiguration.BindingMode == BindingMode.TwoWay) 23 | { 24 | _boundControl.ChildExitingTree += OnChildExitingTree; 25 | } 26 | base.BindControl(bindingConfiguration); 27 | } 28 | 29 | private void OnChildExitingTree(Node node) 30 | { 31 | if (_nodeChildCache.TryGetControlListValue(node.GetInstanceId(), out var listItem)) 32 | { 33 | int itemIndex = _nodeChildCache.GetControlIndex(node.GetInstanceId()); 34 | _nodeChildCache.Remove(listItem, node.GetInstanceId()); 35 | (_bindingConfiguration.TargetObject.Target as IList)?.RemoveAt(itemIndex); 36 | } 37 | } 38 | 39 | public override bool CanBindFor(object control) 40 | { 41 | return control is Godot.Control; 42 | } 43 | 44 | public override IControlBinder CreateInstance() 45 | { 46 | return new GenericControlBinder(); 47 | } 48 | 49 | public void OnListItemChanged(object entry) 50 | { 51 | // do nothing 52 | } 53 | 54 | public void OnObservableListChanged(object sender, NotifyCollectionChangedEventArgs eventArgs) 55 | { 56 | // this should only be used to manage control children using a scene formatter 57 | if (_bindingConfiguration.SceneFormatter == null) 58 | { 59 | return; 60 | } 61 | 62 | // Add new child items 63 | if (eventArgs.Action == NotifyCollectionChangedAction.Add) 64 | { 65 | AddItems(eventArgs.NewItems, eventArgs.NewStartingIndex); 66 | 67 | if (eventArgs.NewStartingIndex != _boundControl.GetChildCount() - 1) 68 | { 69 | // this is an insert event 70 | // we need to move the item to the correct position 71 | var item = _boundControl.GetChild(_boundControl.GetChildCount() - 1); 72 | _boundControl.MoveChild(item, eventArgs.NewStartingIndex); 73 | _nodeChildCache.Insert(item.GetInstanceId(), eventArgs.NewStartingIndex); 74 | } 75 | } 76 | 77 | // Remove child items 78 | if (eventArgs.Action == NotifyCollectionChangedAction.Remove) 79 | { 80 | RemoveItems(eventArgs.OldItems, eventArgs.OldStartingIndex); 81 | } 82 | 83 | // Replace a child item 84 | if (eventArgs.Action == NotifyCollectionChangedAction.Replace) 85 | { 86 | RemoveItems(eventArgs.OldItems, eventArgs.OldStartingIndex); 87 | var newSceneItems = AddItems(eventArgs.NewItems, eventArgs.NewStartingIndex); 88 | 89 | _boundControl.MoveChild(newSceneItems[0], eventArgs.NewStartingIndex); 90 | _nodeChildCache.Move(newSceneItems[0].GetInstanceId(), eventArgs.NewStartingIndex); 91 | } 92 | 93 | // Move a child item 94 | if (eventArgs.Action == NotifyCollectionChangedAction.Move) 95 | { 96 | int oldIndex = eventArgs.OldStartingIndex; 97 | int newIndex = eventArgs.NewStartingIndex; 98 | for (int i = 0; i < eventArgs.NewItems.Count; i++) 99 | { 100 | var item = _boundControl.GetChild(oldIndex); 101 | _boundControl.MoveChild(item, newIndex); 102 | _nodeChildCache.Move(item.GetInstanceId(), newIndex); 103 | oldIndex++; newIndex++; 104 | } 105 | } 106 | 107 | // Clear all child items 108 | if (eventArgs.Action == NotifyCollectionChangedAction.Reset) 109 | { 110 | _nodeChildCache.Clear(); 111 | if (_boundControl != null) 112 | { 113 | foreach (var child in _boundControl.GetChildren()) 114 | { 115 | _boundControl.RemoveChild(child); 116 | child.QueueFree(); 117 | } 118 | } 119 | } 120 | } 121 | 122 | private List AddItems(IList newItems, int newIndex) 123 | { 124 | int i = newIndex; 125 | var newScenes = new List(); 126 | foreach (var addition in newItems) 127 | { 128 | var sceneItem = _bindingConfiguration.SceneFormatter.Format(addition); 129 | _boundControl.AddChild(sceneItem); 130 | newScenes.Add(sceneItem); 131 | // list item references are cached against a node ID so they can be removed from the 132 | // list of children when removed from the backing ObservableList 133 | _nodeChildCache.Add(addition, sceneItem.GetInstanceId(), i); 134 | i++; 135 | } 136 | return newScenes; 137 | } 138 | 139 | private void RemoveItems(IList oldItems, int OldStartingIndex) 140 | { 141 | foreach (var removedItem in oldItems) 142 | { 143 | // get the corresponding scene item 144 | if (!_nodeChildCache.TryGetListItemControlValue(removedItem, out var instanceId)) 145 | { 146 | return; 147 | } 148 | _nodeChildCache.Remove(removedItem, instanceId); 149 | var sceneItem = _boundControl.GetChildren().FirstOrDefault(x => x.GetInstanceId() == instanceId); 150 | if (sceneItem != null) 151 | { 152 | _boundControl.RemoveChild(sceneItem); 153 | sceneItem.QueueFree(); 154 | } 155 | } 156 | } 157 | 158 | public void OnControlChildListChanged(Control control, NotifyCollectionChangedEventArgs args) 159 | { 160 | ControlChildListChanged?.Invoke(control, args); 161 | } 162 | } -------------------------------------------------------------------------------- /Binding/ControlBinders/ItemListControlBinder.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Collections; 2 | using Godot.Community.ControlBinding.Utilities; 3 | using System; 4 | using System.Collections; 5 | using System.Collections.Specialized; 6 | using System.Linq; 7 | 8 | namespace Godot.Community.ControlBinding.ControlBinders; 9 | internal partial class ItemListControlBinder : ControlBinder, IListControlBinder 10 | { 11 | public event IListControlBinder.ControlChildListChangedEventHandler ControlChildListChanged; 12 | 13 | public void OnObservableListChanged(object sender, NotifyCollectionChangedEventArgs eventArgs) 14 | { 15 | if (_bindingConfiguration.BoundControl == null) 16 | { 17 | GD.PrintErr("OptionButtonControlBinder: BoundControl is not set"); 18 | return; 19 | } 20 | 21 | ItemList itemList = _bindingConfiguration.BoundControl.Target as ItemList; 22 | 23 | // Add new items 24 | if (eventArgs.Action == NotifyCollectionChangedAction.Add) 25 | { 26 | itemList.AddListItems(eventArgs.NewItems, _bindingConfiguration.Formatter); 27 | if (eventArgs.NewStartingIndex != itemList.ItemCount - 1) 28 | { 29 | // this is an insert event 30 | // we need to move the item to the correct position 31 | itemList.RedrawItems(_bindingConfiguration.TargetObject.Target as IList, _bindingConfiguration.Formatter); 32 | itemList.EmitSignal(ItemList.SignalName.ItemSelected, eventArgs.NewStartingIndex); 33 | } 34 | } 35 | 36 | // Replace an item 37 | if (eventArgs.Action == NotifyCollectionChangedAction.Replace) 38 | { 39 | var selectedItems = itemList.GetSelectedItems(); 40 | bool itemWasSelected = false; 41 | if (selectedItems.Contains(eventArgs.NewStartingIndex)) 42 | itemWasSelected = true; 43 | 44 | itemList.RemoveItem(eventArgs.NewStartingIndex); 45 | itemList.AddListItems(eventArgs.NewItems, _bindingConfiguration.Formatter); 46 | itemList.RedrawItems(_bindingConfiguration.TargetObject.Target as IList, _bindingConfiguration.Formatter); 47 | 48 | if (itemWasSelected) 49 | { 50 | itemList.Select(eventArgs.NewStartingIndex); 51 | itemList.EmitSignal(ItemList.SignalName.ItemSelected, eventArgs.NewStartingIndex); 52 | } 53 | } 54 | 55 | // Remove items 56 | if (eventArgs.Action == NotifyCollectionChangedAction.Remove) 57 | { 58 | bool itemsSelected = itemList.GetSelectedItems().Any(); 59 | itemList.RemoveItem(eventArgs.OldStartingIndex); 60 | 61 | if (itemsSelected && itemList.ItemCount == 0) 62 | { 63 | itemList.DeselectAll(); 64 | } 65 | 66 | if (itemList.SelectMode == ItemList.SelectModeEnum.Single) 67 | { 68 | if (itemsSelected && itemList.ItemCount > 0) 69 | { 70 | var newIndex = eventArgs.OldStartingIndex - 1 <= 0 ? 0 : eventArgs.OldStartingIndex - 1; 71 | itemList.Select(newIndex); 72 | itemList.EmitSignal(ItemList.SignalName.ItemSelected, newIndex); 73 | } 74 | else 75 | { 76 | itemList.DeselectAll(); 77 | itemList.EmitSignal(ItemList.SignalName.ItemSelected, -1); 78 | } 79 | } 80 | } 81 | 82 | // Move an item 83 | if (eventArgs.Action == NotifyCollectionChangedAction.Move) 84 | { 85 | IList items = _bindingConfiguration.TargetObject.Target as IList; 86 | int newIndex = eventArgs.NewStartingIndex; 87 | 88 | if (newIndex > items.Count - 1) 89 | return; 90 | 91 | // fake a move by updating the items 92 | itemList.RedrawItems(items, _bindingConfiguration.Formatter); 93 | itemList.UpdateSelections(newIndex, eventArgs.OldStartingIndex); 94 | } 95 | 96 | // Clear the list 97 | if (eventArgs.Action == NotifyCollectionChangedAction.Reset) 98 | { 99 | itemList.Clear(); 100 | } 101 | } 102 | 103 | public void OnListItemChanged(object entry) 104 | { 105 | var observableList = _bindingConfiguration.TargetObject.Target as IList; 106 | ItemList itemList = _bindingConfiguration.BoundControl.Target as ItemList; 107 | 108 | var changedIndex = observableList.IndexOf(entry); 109 | object convertedVal = entry; 110 | if (_bindingConfiguration.Formatter != null) 111 | { 112 | convertedVal = _bindingConfiguration.Formatter.FormatControl(entry, null); 113 | } 114 | 115 | if (convertedVal is ListItem listItem) 116 | { 117 | itemList.SetItemValues(changedIndex, listItem); 118 | } 119 | else 120 | { 121 | itemList.SetItemText(changedIndex, convertedVal.ToString()); 122 | } 123 | } 124 | public override IControlBinder CreateInstance() 125 | { 126 | return new ItemListControlBinder(); 127 | } 128 | 129 | public override bool CanBindFor(object control) 130 | { 131 | return control is ItemList; 132 | } 133 | 134 | public void OnControlChildListChanged(Control control, NotifyCollectionChangedEventArgs args) 135 | { 136 | ControlChildListChanged?.Invoke(control, args); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /Binding/ControlBinders/LineEditControlBinder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Specialized; 4 | 5 | namespace Godot.Community.ControlBinding.ControlBinders; 6 | internal partial class LineEditControlBinder : ControlBinder 7 | { 8 | private readonly List _allowedTwoBindingProperties = new(){ 9 | nameof(LineEdit.Text) 10 | }; 11 | 12 | public override void BindControl(BindingConfiguration bindingConfiguration) 13 | { 14 | if (_allowedTwoBindingProperties.Contains(bindingConfiguration.BoundPropertyName) && 15 | (bindingConfiguration.BindingMode == BindingMode.TwoWay || bindingConfiguration.BindingMode == BindingMode.OneWayToTarget)) 16 | { 17 | LineEdit boundControl = bindingConfiguration.BoundControl.Target as LineEdit; 18 | 19 | if (bindingConfiguration.BoundPropertyName == nameof(LineEdit.Text)) 20 | { 21 | boundControl.TextChanged += OnTextChanged; 22 | } 23 | } 24 | 25 | base.BindControl(bindingConfiguration); 26 | } 27 | 28 | public void OnTextChanged(string value) 29 | { 30 | OnControlValueChanged(_bindingConfiguration.BoundControl.Target as Godot.Control, "Text"); 31 | } 32 | 33 | public override IControlBinder CreateInstance() 34 | { 35 | return new LineEditControlBinder(); 36 | } 37 | 38 | public override bool CanBindFor(object control) 39 | { 40 | return control is LineEdit; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Binding/ControlBinders/OptionButtonControlBinder.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Collections; 2 | using Godot.Community.ControlBinding.Utilities; 3 | using System.Collections; 4 | using System.Collections.Generic; 5 | using System.Collections.Specialized; 6 | using System.Linq; 7 | 8 | namespace Godot.Community.ControlBinding.ControlBinders; 9 | internal partial class OptionButtonControlBinder : ControlBinder, IListControlBinder 10 | { 11 | private readonly List _allowedTwoBindingProperties = new(){ 12 | "Selected" 13 | }; 14 | 15 | public event IListControlBinder.ControlChildListChangedEventHandler ControlChildListChanged; 16 | 17 | public override void BindControl(BindingConfiguration bindingConfiguration) 18 | { 19 | if (IsBound) 20 | return; 21 | 22 | if ((bindingConfiguration.BindingMode == BindingMode.OneWayToTarget || bindingConfiguration.BindingMode == BindingMode.TwoWay) 23 | && _allowedTwoBindingProperties.Contains(bindingConfiguration.BoundPropertyName)) 24 | { 25 | OptionButton boundControl = bindingConfiguration.BoundControl.Target as OptionButton; 26 | 27 | if (bindingConfiguration.BoundPropertyName == "Selected") 28 | { 29 | boundControl.ItemSelected += OnItemSelected; 30 | } 31 | } 32 | 33 | base.BindControl(bindingConfiguration); 34 | } 35 | 36 | public void OnItemSelected(long selectedValue) 37 | { 38 | OnControlValueChanged(_bindingConfiguration.BoundControl.Target as Godot.Control, "Selected"); 39 | } 40 | 41 | public void OnObservableListChanged(object sender, NotifyCollectionChangedEventArgs eventArgs) 42 | { 43 | if (_bindingConfiguration.BoundControl == null) 44 | { 45 | GD.PrintErr("OptionButtonControlBinder: BoundControl is not set"); 46 | return; 47 | } 48 | 49 | OptionButton optionButton = (OptionButton)_bindingConfiguration.BoundControl.Target; 50 | 51 | // Add new items 52 | if (eventArgs.Action == NotifyCollectionChangedAction.Add) 53 | { 54 | optionButton.AddListItems(eventArgs.NewItems, _bindingConfiguration.Formatter); 55 | if (eventArgs.NewStartingIndex != optionButton.ItemCount - 1) 56 | { 57 | // this is an insert event 58 | // we need to move the item to the correct position 59 | optionButton.RedrawItems(_bindingConfiguration.TargetObject.Target as IList, _bindingConfiguration.Formatter); 60 | optionButton.EmitSignal(ItemList.SignalName.ItemSelected, eventArgs.NewStartingIndex); 61 | } 62 | } 63 | 64 | // Replace an existing item 65 | if (eventArgs.Action == NotifyCollectionChangedAction.Replace) 66 | { 67 | bool itemWasSelected = optionButton.Selected == eventArgs.NewStartingIndex; 68 | 69 | optionButton.RemoveItem(eventArgs.NewStartingIndex); 70 | optionButton.AddListItems(eventArgs.NewItems, _bindingConfiguration.Formatter); 71 | optionButton.RedrawItems(_bindingConfiguration.TargetObject.Target as IList, _bindingConfiguration.Formatter); 72 | 73 | if (itemWasSelected) 74 | { 75 | optionButton.Select(eventArgs.NewStartingIndex); 76 | optionButton.EmitSignal(ItemList.SignalName.ItemSelected, eventArgs.NewStartingIndex); 77 | } 78 | } 79 | 80 | // Remove an item 81 | if (eventArgs.Action == NotifyCollectionChangedAction.Remove) 82 | { 83 | bool itemsSelected = optionButton.Selected != -1; 84 | optionButton.RemoveItem(eventArgs.OldStartingIndex); 85 | optionButton.Select(-1); 86 | 87 | if (itemsSelected && optionButton.ItemCount > 0) 88 | { 89 | var newIndex = eventArgs.OldStartingIndex - 1 <= 0 ? 0 : eventArgs.OldStartingIndex - 1; 90 | optionButton.Select(newIndex); 91 | optionButton.EmitSignal(OptionButton.SignalName.ItemSelected, newIndex); 92 | } 93 | else 94 | { 95 | optionButton.Select(-1); 96 | optionButton.EmitSignal(OptionButton.SignalName.ItemSelected, -1); 97 | } 98 | } 99 | 100 | // Move an item 101 | if (eventArgs.Action == NotifyCollectionChangedAction.Move) 102 | { 103 | IList items = _bindingConfiguration.TargetObject.Target as IList; 104 | int newIndex = eventArgs.NewStartingIndex; 105 | 106 | if (newIndex > items.Count - 1) 107 | return; 108 | 109 | // fake a move by updating the items? 110 | optionButton.RedrawItems(items, _bindingConfiguration.Formatter); 111 | optionButton.UpdateSelections(newIndex, eventArgs.OldStartingIndex); 112 | } 113 | 114 | // Clear the list 115 | if (eventArgs.Action == NotifyCollectionChangedAction.Reset) 116 | { 117 | optionButton.Clear(); 118 | } 119 | } 120 | 121 | public void OnListItemChanged(object entry) 122 | { 123 | var observableList = _bindingConfiguration.TargetObject.Target as IList; 124 | OptionButton optionButton = _bindingConfiguration.BoundControl.Target as OptionButton; 125 | 126 | var changedIndex = observableList.IndexOf(entry); 127 | object convertedVal = entry; 128 | if (_bindingConfiguration.Formatter != null) 129 | { 130 | convertedVal = _bindingConfiguration.Formatter.FormatControl(entry, null); 131 | } 132 | 133 | if (convertedVal is ListItem listItem) 134 | { 135 | optionButton.SetItemValues(changedIndex, listItem); 136 | } 137 | else 138 | { 139 | optionButton.SetItemText(changedIndex, convertedVal.ToString()); 140 | } 141 | } 142 | 143 | public override IControlBinder CreateInstance() 144 | { 145 | return new OptionButtonControlBinder(); 146 | } 147 | 148 | public override bool CanBindFor(object control) 149 | { 150 | return control is OptionButton; 151 | } 152 | 153 | public void OnControlChildListChanged(Control control, NotifyCollectionChangedEventArgs args) 154 | { 155 | ControlChildListChanged?.Invoke(control, args); 156 | } 157 | } -------------------------------------------------------------------------------- /Binding/ControlBinders/RangeControlBinder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Godot.Community.ControlBinding.ControlBinders; 4 | 5 | internal partial class RangeControlBinder : ControlBinder 6 | { 7 | private readonly List _allowedTwoBindingProperties = new() 8 | { 9 | nameof(Range.Value) 10 | }; 11 | 12 | public override void BindControl(BindingConfiguration bindingConfiguration) 13 | { 14 | if ((bindingConfiguration.BindingMode == BindingMode.OneWayToTarget || bindingConfiguration.BindingMode == BindingMode.TwoWay) 15 | && _allowedTwoBindingProperties.Contains(bindingConfiguration.BoundPropertyName)) 16 | { 17 | Godot.Range boundControl = bindingConfiguration.BoundControl.Target as Range; 18 | 19 | if (bindingConfiguration.BoundPropertyName == nameof(Range.Value)) 20 | boundControl.ValueChanged += OnValueChanged; 21 | } 22 | 23 | base.BindControl(bindingConfiguration); 24 | } 25 | 26 | public void OnValueChanged(double value) 27 | { 28 | OnControlValueChanged(_bindingConfiguration.BoundControl.Target as Godot.Control, "Value"); 29 | } 30 | 31 | public override bool CanBindFor(object control) 32 | { 33 | return control is Range; 34 | } 35 | 36 | public override IControlBinder CreateInstance() 37 | { 38 | return new RangeControlBinder(); 39 | } 40 | } -------------------------------------------------------------------------------- /Binding/ControlBinders/TextEditControlBinder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Godot.Community.ControlBinding.ControlBinders; 4 | 5 | internal partial class TextEditControlBinder : ControlBinder 6 | { 7 | private readonly List _allowedTwoBindingProperties = new(){ 8 | nameof(TextEdit.Text) 9 | }; 10 | 11 | public override void BindControl(BindingConfiguration bindingConfiguration) 12 | { 13 | if ((bindingConfiguration.BindingMode == BindingMode.OneWayToTarget || bindingConfiguration.BindingMode == BindingMode.TwoWay) 14 | && _allowedTwoBindingProperties.Contains(bindingConfiguration.BoundPropertyName)) 15 | { 16 | TextEdit boundControl = bindingConfiguration.BoundControl.Target as TextEdit; 17 | if (bindingConfiguration.BoundPropertyName == "Text") 18 | { 19 | boundControl.TextChanged += OnTextChanged; 20 | } 21 | } 22 | 23 | base.BindControl(bindingConfiguration); 24 | } 25 | 26 | public void OnTextChanged() 27 | { 28 | OnControlValueChanged(_bindingConfiguration.BoundControl.Target as Godot.Control, "Text"); 29 | } 30 | 31 | public override IControlBinder CreateInstance() 32 | { 33 | return new TextEditControlBinder(); 34 | } 35 | 36 | public override bool CanBindFor(object control) 37 | { 38 | return control is TextEdit; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Binding/EventHandlers/PropertyChangedEventHandler.cs: -------------------------------------------------------------------------------- 1 | 2 | namespace Godot.Community.ControlBinding 3 | { 4 | public delegate void ValidationChangedEventHandler(Godot.Control control, string targetPropertyName, string message, bool isValid); 5 | } -------------------------------------------------------------------------------- /Binding/Exceptions/ValidationException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Godot.Community.ControlBinding 4 | { 5 | [Serializable] 6 | public class ValidationException : Exception 7 | { 8 | public ValidationException() : base() 9 | { 10 | } 11 | 12 | public ValidationException(string message) : base(message) 13 | { 14 | } 15 | 16 | public ValidationException(string message, Exception innerException) : base(message, innerException) 17 | { 18 | } 19 | 20 | protected ValidationException(System.Runtime.Serialization.SerializationInfo serializationInfo, System.Runtime.Serialization.StreamingContext streamingContext) : base(serializationInfo, streamingContext) 21 | { 22 | 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /Binding/Extensions/NodeExtensions.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Formatters; 2 | using System; 3 | 4 | namespace Godot.Community.ControlBinding.Extensions; 5 | public static class NodeExtensions 6 | { 7 | /// 8 | /// Binds an emum to an OptionButton control with optional path for the selected value 9 | /// 10 | /// The path of the Godot control in the scene. 11 | /// The path of the property to bind to. Relative to this object. 12 | /// The enum type to bind the OptionButton to 13 | public static void BindEnumProperty(this OptionButton node, BindingContext bindingContainer, string selectedItemPath = null) where T : Enum 14 | { 15 | bindingContainer.BindEnumProperty(node, selectedItemPath); 16 | } 17 | 18 | public static void BindSceneList( 19 | this Node node, 20 | BindingContext bindingContainer, 21 | string path, 22 | string scenePath, 23 | BindingMode bindingMode = BindingMode.OneWay) 24 | { 25 | bindingContainer.BindSceneList(node, path, scenePath, bindingMode); 26 | } 27 | 28 | /// 29 | /// Bind a list control to an IObservableList or IList property 30 | /// Note: list controls include OptionButton and ItemList 31 | /// 32 | /// The path of the Godot control in the scene. 33 | /// The path of the property to bind to. Relative to this object. 34 | /// The binding mode to use 35 | /// The IValueFormatter to use to format the list item and target property. Return a for greater formatting control. 36 | public static void BindListProperty( 37 | this Node node, 38 | BindingContext bindingContainer, 39 | 40 | string path, 41 | BindingMode bindingMode = BindingMode.OneWay, 42 | IValueFormatter formatter = null 43 | ) 44 | { 45 | bindingContainer.BindListProperty(node, path, bindingMode, formatter); 46 | } 47 | 48 | /// 49 | /// Bind a control property to an object property 50 | /// 51 | /// The node to bind to 52 | /// The property of the Godot control to bind to 53 | /// The path of the property to bind to. Relative to this object 54 | /// The binding mode to use 55 | /// The to use to format the Control property and target property 56 | public static Factories.BindingBuilder BindProperty( 57 | this Node node, 58 | BindingContext bindingContainer, 59 | string sourceProperty, 60 | string path, 61 | BindingMode bindingMode = BindingMode.OneWay, 62 | IValueFormatter formatter = null 63 | ) 64 | { 65 | return bindingContainer.BindProperty(node, sourceProperty, path, bindingMode, formatter); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Binding/Factories/BindingBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Godot.Community.ControlBinding.Factories 4 | { 5 | public class BindingBuilderBase 6 | { 7 | internal readonly Binding _binding; 8 | internal BindingBuilderBase(Binding binding) 9 | { 10 | _binding = binding; 11 | } 12 | } 13 | 14 | public class BindingValidatorBuilder : BindingBuilderBase where T : BindingValidatorBuilder 15 | { 16 | internal BindingValidatorBuilder(Binding binding) : base(binding) 17 | { 18 | } 19 | 20 | public virtual T AddValidator(Func validator) 21 | { 22 | _binding.BoundPropertySetter.AddValidator(validator); 23 | return (T)this; 24 | } 25 | } 26 | 27 | public class BindingBuilderValidationHandler : BindingValidatorBuilder where T : BindingBuilderValidationHandler 28 | { 29 | internal BindingBuilderValidationHandler(Binding binding) : base(binding) 30 | { 31 | } 32 | 33 | public BindingValidatorBuilder AddValidationHandler(Action handler) 34 | { 35 | _binding.BindingConfiguration.OnValidationChangedHandler = handler; 36 | return (T)this; 37 | } 38 | } 39 | 40 | public class BindingBuilder : BindingBuilderValidationHandler 41 | { 42 | internal BindingBuilder(Binding binding) : base(binding) 43 | { 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /Binding/Formatters/ISceneFormatter.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Utilities; 2 | 3 | namespace Godot.Community.ControlBinding.Formatters; 4 | 5 | public interface ISceneFormatter 6 | { 7 | public string ScenePath { get; } 8 | public Node Format(object viewModelData) 9 | { 10 | return SceneInstancer.CreateSceneInstance(ScenePath, viewModelData); 11 | } 12 | } -------------------------------------------------------------------------------- /Binding/Formatters/IValueFormatter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Godot.Community.ControlBinding.Formatters; 4 | 5 | /// 6 | /// The interface to implement to create a value formatter that formats data before being set on the target control or object 7 | /// 8 | public interface IValueFormatter 9 | { 10 | public Func FormatControl { get; } 11 | public Func FormatTarget { get; } 12 | } 13 | 14 | /// 15 | /// Formats data before being set on the target control or object 16 | /// 17 | public class ValueFormatter : IValueFormatter 18 | { 19 | public Func FormatControl { get; init; } 20 | public Func FormatTarget { get; init; } 21 | } -------------------------------------------------------------------------------- /Binding/Formatters/ReverseBoolValueFormatter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Godot.Community.ControlBinding.Formatters; 4 | 5 | public class ReverseBoolValueFormatter : IValueFormatter 6 | { 7 | public Func FormatControl => (v, _) => !(bool)v; 8 | 9 | public Func FormatTarget => (v, _) => !(bool)v; 10 | } 11 | -------------------------------------------------------------------------------- /Binding/Formatters/SceneFormatter.cs: -------------------------------------------------------------------------------- 1 | namespace Godot.Community.ControlBinding.Formatters 2 | { 3 | public class SceneFormatter : ISceneFormatter 4 | { 5 | public string ScenePath { get; init; } 6 | } 7 | } -------------------------------------------------------------------------------- /Binding/Interfaces/IControlBinder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Specialized; 2 | 3 | namespace Godot.Community.ControlBinding.ControlBinders; 4 | internal interface IControlBinder 5 | { 6 | bool CanBindFor(System.Object control); 7 | void BindControl(BindingConfiguration bindingConfiguration); 8 | IControlBinder CreateInstance(); 9 | bool IsBound { get; set; } 10 | int Priority { get; } 11 | } -------------------------------------------------------------------------------- /Binding/Interfaces/IListControlBinder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Specialized; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | 7 | namespace Godot.Community.ControlBinding.ControlBinders 8 | { 9 | public interface IListControlBinder 10 | { 11 | public delegate void ControlChildListChangedEventHandler(Godot.Control control, NotifyCollectionChangedEventArgs args); 12 | public event ControlChildListChangedEventHandler ControlChildListChanged; 13 | public void OnControlChildListChanged(Godot.Control control, NotifyCollectionChangedEventArgs args); 14 | public void OnListItemChanged(object entry); 15 | public void OnObservableListChanged(object sender, NotifyCollectionChangedEventArgs eventArgs); 16 | } 17 | } -------------------------------------------------------------------------------- /Binding/Interfaces/IObservableObject.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel; 2 | 3 | namespace Godot.Community.ControlBinding.Interfaces; 4 | 5 | public interface IObservableObject : INotifyPropertyChanged 6 | { 7 | } -------------------------------------------------------------------------------- /Binding/Interfaces/IViewModel.cs: -------------------------------------------------------------------------------- 1 | namespace Godot.Community.ControlBinding.Interfaces 2 | { 3 | public interface IViewModel : IObservableObject 4 | { 5 | void SetViewModelData(object viewModelData); 6 | } 7 | } -------------------------------------------------------------------------------- /Binding/ObservableObject.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Interfaces; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Runtime.CompilerServices; 5 | 6 | namespace Godot.Community.ControlBinding; 7 | 8 | public partial class ObservableObject : IObservableObject 9 | { 10 | public event PropertyChangedEventHandler PropertyChanged; 11 | /// 12 | public void OnPropertyChanged([CallerMemberName] string name = "not a property") 13 | { 14 | if (name == "not a property") 15 | return; 16 | 17 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); 18 | } 19 | public virtual void SetValue(ref T field, T value, [CallerMemberName] string name = "not a property") 20 | { 21 | if (EqualityComparer.Default.Equals(field, value)) 22 | return; 23 | 24 | field = value; 25 | OnPropertyChanged(name); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Binding/Services/BackReferenceFactory.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Interfaces; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Collections.Specialized; 5 | using System.Linq; 6 | 7 | namespace Godot.Community.ControlBinding.Services; 8 | 9 | internal static class BackReferenceFactory 10 | { 11 | public static List GetPathObjectsAndBuildBackReferences(List pathNodes, ref BindingConfiguration bindingConfiguration) 12 | { 13 | bindingConfiguration.BackReferences?.Clear(); 14 | bindingConfiguration.BackReferences = new(); 15 | 16 | List pathObjects = new() 17 | { 18 | bindingConfiguration.Owner 19 | }; 20 | 21 | if (pathNodes.Count > 1) 22 | { 23 | object root = bindingConfiguration.Owner; 24 | object pathObject = root; 25 | for (int i = 0; i < pathNodes.Count; i++) 26 | { 27 | if (root == null) 28 | continue; 29 | 30 | if (i == 0) 31 | { 32 | pathObjects.Add(root); 33 | } 34 | else 35 | { 36 | var pathNode = pathNodes[i - 1]; 37 | pathObject = ReflectionService.GetPropertyInfo(root, pathNode)?.GetValue(root); 38 | 39 | pathObjects.Add(pathObject); 40 | } 41 | 42 | if (!bindingConfiguration.IsListBinding && i + 1 > pathNodes.Count - 1) 43 | { 44 | continue; 45 | } 46 | 47 | if (!bindingConfiguration.BackReferences.Any(x => x.ObjectReference.Target == pathObject && x.PropertyName == pathNodes[i])) 48 | { 49 | bindingConfiguration.BackReferences.Add(new WeakBackReference 50 | { 51 | ObjectReference = new WeakReference(pathObject), 52 | PropertyName = pathNodes[i], 53 | }); 54 | } 55 | 56 | root = pathObject; 57 | } 58 | } 59 | 60 | if (bindingConfiguration.IsListBinding) 61 | { 62 | pathNodes = pathNodes.Where(x => !string.IsNullOrEmpty(x)).ToList(); 63 | if (pathNodes.Count == 1) 64 | { 65 | pathObjects.Add(bindingConfiguration.Owner); 66 | } 67 | 68 | // Support enum bindings 69 | if (pathNodes.Count == 0 && bindingConfiguration.TargetObject.Target != null) 70 | { 71 | pathObjects.Add(bindingConfiguration.TargetObject.Target); 72 | } 73 | 74 | if (pathObjects.Last() is IObservableObject observableObject && pathObjects.Last() is not INotifyCollectionChanged) 75 | { 76 | var list = ReflectionService.GetPropertyInfo(observableObject, pathNodes.Last()).GetValue(observableObject); 77 | pathObjects.Add(list); 78 | 79 | bindingConfiguration.BackReferences.Add(new WeakBackReference 80 | { 81 | ObjectReference = new WeakReference(pathObjects[^2], false), 82 | PropertyName = pathNodes.Last() 83 | }); 84 | } 85 | } 86 | 87 | return pathObjects; 88 | } 89 | } -------------------------------------------------------------------------------- /Binding/Services/ControlBinderProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | 4 | namespace Godot.Community.ControlBinding.ControlBinders 5 | { 6 | internal static class ControlBinderProvider 7 | { 8 | private static readonly List _binders = new() 9 | { 10 | new LineEditControlBinder(), 11 | new CheckBoxControlBinder(), 12 | new OptionButtonControlBinder(), 13 | new TextEditControlBinder(), 14 | new RangeControlBinder(), 15 | new ItemListControlBinder(), 16 | new GenericControlBinder(), 17 | }; 18 | 19 | public static IControlBinder GetBinder(object sourceObject) 20 | { 21 | var binder = _binders 22 | .OrderByDescending(x => x.Priority) 23 | .FirstOrDefault(x => x.CanBindFor(sourceObject)); 24 | 25 | if (binder == null) 26 | { 27 | GD.PrintErr($"Cannot find binder for {sourceObject.GetType()}"); 28 | return null; 29 | } 30 | 31 | var binderInstance = binder.CreateInstance(); 32 | return binderInstance; 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /Binding/Services/NodeChildCache.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Godot.Community.ControlBinding.Services 7 | { 8 | internal class NodeChildCache 9 | { 10 | private readonly Dictionary _controlChildCache = new(); 11 | private readonly Dictionary _controlChildCacheReverseLookup = new(); 12 | private readonly Dictionary _controlChildIndexes = new(); 13 | 14 | public void Add(object listItem, ulong sceneInstanceId, int index) 15 | { 16 | _controlChildCache.Add(listItem, sceneInstanceId); 17 | _controlChildCacheReverseLookup.Add(sceneInstanceId, listItem); 18 | _controlChildIndexes.Add(sceneInstanceId, index); 19 | } 20 | 21 | public void Remove(object listItem, ulong sceneInstanceId) 22 | { 23 | _controlChildCache.Remove(listItem); 24 | _controlChildCacheReverseLookup.Remove(sceneInstanceId); 25 | var index = _controlChildIndexes[sceneInstanceId]; 26 | _controlChildIndexes.Remove(sceneInstanceId); 27 | foreach (var itemIndex in _controlChildIndexes) 28 | { 29 | if (itemIndex.Value > index) 30 | { 31 | _controlChildIndexes[itemIndex.Key]--; 32 | } 33 | } 34 | } 35 | 36 | public void Insert(ulong sceneInstanceId, int newIndex) 37 | { 38 | _controlChildIndexes[sceneInstanceId] = newIndex; 39 | 40 | foreach (var itemIndex in _controlChildIndexes) 41 | { 42 | if (itemIndex.Value >= newIndex && itemIndex.Key != sceneInstanceId) 43 | { 44 | _controlChildIndexes[itemIndex.Key]++; 45 | } 46 | } 47 | } 48 | 49 | public void Move(ulong sceneInstanceId, int newIndex) 50 | { 51 | var oldIndex = _controlChildIndexes[sceneInstanceId]; 52 | foreach (var itemIndex in _controlChildIndexes) 53 | { 54 | if (itemIndex.Value > oldIndex && itemIndex.Value <= newIndex) 55 | { 56 | _controlChildIndexes[itemIndex.Key]--; 57 | } 58 | else if (itemIndex.Value > newIndex && itemIndex.Value <= oldIndex) 59 | { 60 | _controlChildIndexes[itemIndex.Key]++; 61 | } 62 | } 63 | _controlChildIndexes[sceneInstanceId] = newIndex; 64 | } 65 | 66 | public void Clear() 67 | { 68 | _controlChildCache.Clear(); 69 | _controlChildCacheReverseLookup.Clear(); 70 | _controlChildIndexes.Clear(); 71 | } 72 | 73 | public int GetControlIndex(ulong sceneInstanceId) 74 | { 75 | return _controlChildIndexes[sceneInstanceId]; 76 | } 77 | 78 | public object GetControlListItem(ulong sceneInstanceId) 79 | { 80 | return _controlChildCacheReverseLookup[sceneInstanceId]; 81 | } 82 | 83 | public bool TryGetControlListValue(ulong sceneInstanceId, out object listItem) 84 | { 85 | return _controlChildCacheReverseLookup.TryGetValue(sceneInstanceId, out listItem); 86 | } 87 | 88 | public bool TryGetListItemControlValue(object item, out ulong sceneInstanceId) 89 | { 90 | return _controlChildCache.TryGetValue(item, out sceneInstanceId); 91 | } 92 | 93 | } 94 | } -------------------------------------------------------------------------------- /Binding/Services/ReflectionService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Reflection; 3 | 4 | namespace Godot.Community.ControlBinding.Services; 5 | internal static class ReflectionService 6 | { 7 | private static readonly Dictionary _propertyInfoCache = new(); 8 | 9 | public static PropertyInfo GetPropertyInfo(object instance, string propertyName) 10 | { 11 | if (instance == null) 12 | return null; 13 | 14 | string cacheKey = $"{instance.GetType().FullName}.{propertyName}"; 15 | 16 | if (!_propertyInfoCache.ContainsKey(cacheKey)) 17 | { 18 | var pInfo = instance.GetType().GetProperty(propertyName, 19 | System.Reflection.BindingFlags.Public | 20 | System.Reflection.BindingFlags.NonPublic | 21 | System.Reflection.BindingFlags.Instance | 22 | System.Reflection.BindingFlags.GetField 23 | ); 24 | 25 | if (pInfo == null) 26 | return null; 27 | _propertyInfoCache.Add(cacheKey, pInfo); 28 | } 29 | 30 | #pragma warning disable S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields 31 | return _propertyInfoCache[cacheKey]; 32 | #pragma warning restore S3011 // Reflection should not be used to increase accessibility of classes, methods, or fields 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Binding/Utilities/BoundPropertySetter.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Formatters; 2 | using Godot.Community.ControlBinding.Services; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reflection; 7 | 8 | namespace Godot.Community.ControlBinding.Utilities; 9 | 10 | internal class BoundPropertySetter 11 | { 12 | private readonly IValueFormatter _valueFormatter; 13 | private readonly List> _validators; 14 | public BoundPropertySetter(IValueFormatter valueFormatter, List> validators = null) 15 | { 16 | _valueFormatter = valueFormatter; 17 | _validators = validators; 18 | } 19 | 20 | private enum SetDirection 21 | { 22 | ToProperty, 23 | ToControl 24 | } 25 | 26 | public void SetBoundPropertyValue( 27 | Godot.Control sourceControl, 28 | string sourcePropertyName, 29 | object targetObject, 30 | string targetPropertyName) 31 | { 32 | SetPropertyValue(sourceControl, sourcePropertyName, targetObject, targetPropertyName, _valueFormatter?.FormatTarget, SetDirection.ToProperty); 33 | } 34 | 35 | public void SetBoundControlValue(object sourceObject, string sourcePropertyName, Godot.Control targetControl, string targetPropertyName) 36 | { 37 | SetPropertyValue(sourceObject, sourcePropertyName, targetControl, targetPropertyName, _valueFormatter?.FormatControl, SetDirection.ToControl); 38 | } 39 | 40 | public void AddValidator(Func validator) 41 | { 42 | _validators.Add(validator); 43 | } 44 | 45 | private void SetPropertyValue( 46 | object sourceObject, 47 | string sourcePropertyName, 48 | object targetObject, 49 | string targetPropertyName, 50 | Func formatter, 51 | SetDirection direction) 52 | { 53 | if (sourceObject is null && targetObject is not null) 54 | { 55 | var propertyInfo = ReflectionService.GetPropertyInfo(targetObject, targetPropertyName); 56 | propertyInfo.SetValue(targetObject, null); 57 | return; 58 | } 59 | 60 | if (targetObject is null) 61 | { 62 | // can't set a value on a null target 63 | return; 64 | } 65 | 66 | PropertyInfo sourcePropertyInfo = ReflectionService.GetPropertyInfo(sourceObject, sourcePropertyName); 67 | PropertyInfo targetPropertyInfo = ReflectionService.GetPropertyInfo(targetObject, targetPropertyName); 68 | 69 | var sourceValue = sourcePropertyInfo?.GetValue(sourceObject); 70 | var targetValue = targetPropertyInfo?.GetValue(targetObject); 71 | 72 | object convertedValue = sourceValue; 73 | bool formatterFailed = false; 74 | 75 | if (direction == SetDirection.ToProperty && _validators != null && _validators.Any()) 76 | { 77 | foreach (var validator in _validators) 78 | { 79 | var result = validator(sourceValue); 80 | if (!string.IsNullOrEmpty(result)) 81 | { 82 | throw new ValidationException(result); 83 | } 84 | } 85 | } 86 | 87 | if (formatter != null) 88 | { 89 | try 90 | { 91 | convertedValue = formatter(sourceValue, targetValue); 92 | } 93 | catch (ValidationException) 94 | { 95 | throw; 96 | } 97 | catch (Exception ex) 98 | { 99 | GD.PrintErr($"DataBinding: Failed to format target. {ex.Message}"); 100 | formatterFailed = true; 101 | } 102 | } 103 | 104 | if (formatter == null || formatterFailed) 105 | { 106 | try 107 | { 108 | convertedValue = PropertyTypeConverter.ConvertValue( 109 | sourcePropertyInfo.PropertyType, 110 | targetPropertyInfo.PropertyType, 111 | sourceValue); 112 | } 113 | catch 114 | { 115 | convertedValue = null; 116 | } 117 | } 118 | 119 | if (targetValue?.Equals(convertedValue) == true) 120 | return; 121 | 122 | if (targetValue == convertedValue) 123 | return; 124 | 125 | targetPropertyInfo?.SetValue(targetObject, convertedValue); 126 | } 127 | } -------------------------------------------------------------------------------- /Binding/Utilities/ListBindingHelpers.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using Godot; 6 | using Godot.Community.ControlBinding.Collections; 7 | using Godot.Community.ControlBinding.Formatters; 8 | 9 | namespace Godot.Community.ControlBinding.Utilities 10 | { 11 | internal static class ListBindingHelper 12 | { 13 | # region ItemList 14 | public static void RedrawItems(this ItemList itemList, IList items, IValueFormatter formatter) 15 | { 16 | // fake a move by updating the items? 17 | for (int i = 0; i < items.Count; i++) 18 | { 19 | object item = string.Empty; 20 | if (formatter != null) 21 | { 22 | item = formatter.FormatControl(items[i], null); 23 | } 24 | else 25 | { 26 | item = items[i].ToString(); 27 | } 28 | 29 | if (item is string stringValue) 30 | itemList.SetItemText(i, stringValue); 31 | 32 | if (item is ListItem listItem) 33 | { 34 | SetItemValues(itemList, i, listItem); 35 | } 36 | } 37 | } 38 | 39 | public static void SetItemValues(this ItemList itemList, int index, ListItem listItem) 40 | { 41 | itemList.SetItemText(index, listItem.DisplayValue); 42 | if (listItem.Icon != null) 43 | itemList.SetItemIcon(index, listItem.Icon); 44 | if (listItem.Disabled.HasValue) 45 | itemList.SetItemDisabled(index, listItem.Disabled.Value); 46 | if (listItem.Metadata.VariantType != Variant.Type.Nil) 47 | itemList.SetItemMetadata(index, listItem.Metadata); 48 | if (listItem.BackgroundColor.HasValue) 49 | itemList.SetItemCustomBgColor(index, listItem.BackgroundColor.Value); 50 | if (listItem.ForegroundColor.HasValue) 51 | itemList.SetItemCustomFgColor(index, listItem.ForegroundColor.Value); 52 | if (!string.IsNullOrEmpty(listItem.Language)) 53 | itemList.SetItemLanguage(index, listItem.Language); 54 | if (listItem.IconRegion.HasValue) 55 | itemList.SetItemIconRegion(index, listItem.IconRegion.Value); 56 | if (listItem.Selectable.HasValue) 57 | itemList.SetItemSelectable(index, listItem.Selectable.Value); 58 | if (listItem.IconTransposed.HasValue) 59 | itemList.SetItemIconTransposed(index, listItem.IconTransposed.Value); 60 | if (listItem.IconModulate.HasValue) 61 | itemList.SetItemIconModulate(index, listItem.IconModulate.Value); 62 | 63 | itemList.SetItemTooltip(index, listItem.Tooltip); 64 | itemList.SetItemTooltipEnabled(index, !string.IsNullOrEmpty(listItem.Tooltip)); 65 | itemList.SetItemTextDirection(index, listItem.TextDirection); // defaults to auto 66 | } 67 | 68 | public static void AddListItems(this ItemList itemList, IList items, IValueFormatter formatter) 69 | { 70 | var convertedValues = items.Cast().ToList(); 71 | if (formatter != null) 72 | { 73 | convertedValues = convertedValues.ConvertAll(x => formatter.FormatControl(x, null)); 74 | } 75 | foreach (var item in convertedValues) 76 | { 77 | if (item is string stringValue) 78 | itemList.AddItem(stringValue); 79 | 80 | if (item is ListItem listItem) 81 | { 82 | itemList.AddItem(listItem.DisplayValue); 83 | itemList.SetItemValues(itemList.ItemCount - 1, listItem); 84 | } 85 | } 86 | } 87 | 88 | public static void UpdateSelections(this ItemList itemList, int newIndex, int oldIndex) 89 | { 90 | for (int i = 0; i < itemList.ItemCount; i++) 91 | { 92 | bool isSelected = itemList.IsSelected(i); 93 | if (!isSelected) 94 | continue; 95 | 96 | if (i >= oldIndex && i < newIndex) 97 | { 98 | itemList.Deselect(i); 99 | itemList.Select(i + 1); 100 | itemList.EmitSignal(ItemList.SignalName.ItemSelected, i + 1); 101 | } 102 | else if (i > newIndex && i <= oldIndex) 103 | { 104 | itemList.Deselect(i); 105 | itemList.Select(i - 1); 106 | itemList.EmitSignal(ItemList.SignalName.ItemSelected, i - 1); 107 | } 108 | } 109 | } 110 | #endregion 111 | 112 | #region OptionButton 113 | public static void RedrawItems(this OptionButton optionButton, IList items, IValueFormatter formatter) 114 | { 115 | // fake a move by updating the items 116 | for (int i = 0; i < items.Count; i++) 117 | { 118 | object item = string.Empty; 119 | if (formatter != null) 120 | { 121 | item = formatter.FormatControl(items[i], null); 122 | } 123 | else 124 | { 125 | item = items[i].ToString(); 126 | } 127 | 128 | if (item is string stringValue) 129 | optionButton.SetItemText(i, stringValue); 130 | 131 | if (item is ListItem listItem) 132 | { 133 | optionButton.SetItemValues(i, listItem); 134 | } 135 | } 136 | } 137 | public static void SetItemValues(this OptionButton optionButton, int index, ListItem listItem) 138 | { 139 | optionButton.SetItemText(index, listItem.DisplayValue); 140 | if (listItem.Icon != null) 141 | optionButton.SetItemIcon(index, listItem.Icon); 142 | if (listItem.Id != -1) 143 | optionButton.SetItemId(index, listItem.Id); 144 | if (listItem.Disabled.HasValue) 145 | optionButton.SetItemDisabled(index, listItem.Disabled.Value); 146 | if (listItem.Metadata.VariantType != Variant.Type.Nil) 147 | optionButton.SetItemMetadata(index, listItem.Metadata); 148 | } 149 | 150 | public static void AddListItems(this OptionButton optionButton, IList items, IValueFormatter formatter) 151 | { 152 | List convertedValues = items.Cast().ToList(); 153 | if (formatter != null) 154 | { 155 | convertedValues = convertedValues.ConvertAll(x => formatter.FormatControl(x, null)); 156 | } 157 | 158 | foreach (var item in convertedValues) 159 | { 160 | if (item is string stringValue) 161 | { 162 | optionButton.AddItem(stringValue); 163 | } 164 | 165 | if (item is ListItem listItem) 166 | { 167 | optionButton.AddItem(listItem.DisplayValue); 168 | optionButton.SetItemValues(optionButton.ItemCount - 1, listItem); 169 | } 170 | 171 | if (optionButton.ItemCount == 1) 172 | { 173 | optionButton.Select(0); 174 | } 175 | else 176 | { 177 | optionButton.Select(optionButton.Selected); 178 | } 179 | } 180 | } 181 | 182 | public static void UpdateSelections(this OptionButton optionButton, int newIndex, int oldIndex) 183 | { 184 | for (int i = 0; i < optionButton.ItemCount; i++) 185 | { 186 | bool isSelected = optionButton.Selected == i; 187 | if (!isSelected) 188 | continue; 189 | 190 | if (i >= oldIndex && i < newIndex) 191 | { 192 | optionButton.Select(i + 1); 193 | optionButton.EmitSignal(ItemList.SignalName.ItemSelected, i + 1); 194 | } 195 | else if (i > newIndex && i <= oldIndex) 196 | { 197 | optionButton.Select(i - 1); 198 | optionButton.EmitSignal(ItemList.SignalName.ItemSelected, i - 1); 199 | } 200 | } 201 | } 202 | #endregion 203 | } 204 | } -------------------------------------------------------------------------------- /Binding/Utilities/PropertyTypeConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | 4 | namespace Godot.Community.ControlBinding.Utilities; 5 | 6 | internal static class PropertyTypeConverter 7 | { 8 | public static object ConvertValue(Type fromType, Type targetType, object value) 9 | { 10 | if (value == null) 11 | return value; 12 | 13 | object convertedValue = value; 14 | try 15 | { 16 | if (!fromType.Equals(targetType)) 17 | { 18 | var converter = TypeDescriptor.GetConverter(targetType); 19 | if (converter != null) 20 | convertedValue = converter.ConvertTo(value, targetType); 21 | else 22 | convertedValue = System.Convert.ChangeType(value, targetType); 23 | } 24 | } 25 | catch (Exception ex) 26 | { 27 | GD.PrintErr($"ControlBinding: Unable to convert value. {ex.Message}"); 28 | convertedValue = value?.ToString(); 29 | } 30 | 31 | return convertedValue; 32 | } 33 | } -------------------------------------------------------------------------------- /Binding/Utilities/SceneInstancer.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Interfaces; 2 | 3 | namespace Godot.Community.ControlBinding.Utilities; 4 | 5 | internal static class SceneInstancer 6 | { 7 | public static Node CreateSceneInstance(string scenePath, object viewModelData) 8 | { 9 | var scene = (Godot.PackedScene)ResourceLoader.Load(scenePath); 10 | var node = scene.Instantiate(); 11 | if (node is IViewModel viewModel) 12 | viewModel.SetViewModelData(viewModelData); 13 | return node; 14 | } 15 | } -------------------------------------------------------------------------------- /Binding/ViewModel.cs: -------------------------------------------------------------------------------- 1 | using Godot.Community.ControlBinding.Interfaces; 2 | using System.Collections.Generic; 3 | using System.ComponentModel; 4 | using System.Runtime.CompilerServices; 5 | 6 | namespace Godot.Community.ControlBinding; 7 | 8 | public abstract partial class NodeViewModel : Node, IViewModel 9 | { 10 | public event PropertyChangedEventHandler PropertyChanged; 11 | 12 | public virtual void OnPropertyChanged([CallerMemberName] string name = "not a property") 13 | { 14 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); 15 | } 16 | 17 | public abstract void SetViewModelData(object viewModelData); 18 | 19 | public virtual void SetValue(ref T field, T value, [CallerMemberName] string name = "not a property") 20 | { 21 | if (EqualityComparer.Default.Equals(field, value)) 22 | return; 23 | 24 | field = value; 25 | OnPropertyChanged(name); 26 | } 27 | } 28 | 29 | public abstract partial class ControlViewModel : Control, IViewModel 30 | { 31 | public event PropertyChangedEventHandler PropertyChanged; 32 | 33 | public virtual void OnPropertyChanged([CallerMemberName] string name = "not a property") 34 | { 35 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name)); 36 | } 37 | 38 | public abstract void SetViewModelData(object viewModelData); 39 | 40 | public virtual void SetValue(ref T field, T value, [CallerMemberName] string name = "not a property") 41 | { 42 | if (EqualityComparer.Default.Equals(field, value)) 43 | return; 44 | 45 | field = value; 46 | OnPropertyChanged(name); 47 | } 48 | } -------------------------------------------------------------------------------- /Binding/WeakBackReference.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Godot.Community.ControlBinding; 4 | 5 | internal class WeakBackReference 6 | { 7 | public WeakReference ObjectReference { get; set; } 8 | public string PropertyName { get; set; } 9 | } -------------------------------------------------------------------------------- /Godot.Community.ControlBinding.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net6.0 4 | true 5 | True 6 | Godot 4 C# Control Binding 7 | Aidan Danglmaier 8 | WPF-style control bindings for Godot 4 controls 9 | True 10 | MIT 11 | 1.0.1.0 12 | 1.0.1.0 13 | $(AssemblyVersion) 14 | https://github.com/Dangles91/Godot.Community.ControlBinding 15 | git 16 | godot controlbinding 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /Godot.Community.ControlBinding.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.5.33627.172 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Godot.Community.ControlBinding", "Godot.Community.ControlBinding.csproj", "{FE2B3EC2-1CB6-498D-A1A8-CEA8B79D8F2E}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ControlBinding.Example", "examples\basic-bindings\ControlBinding.Example.csproj", "{FD83ED8E-0164-4F9A-A705-D7EE074CCE35}" 9 | EndProject 10 | Global 11 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 12 | Debug|Any CPU = Debug|Any CPU 13 | ExportDebug|Any CPU = ExportDebug|Any CPU 14 | ExportRelease|Any CPU = ExportRelease|Any CPU 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {FE2B3EC2-1CB6-498D-A1A8-CEA8B79D8F2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {FE2B3EC2-1CB6-498D-A1A8-CEA8B79D8F2E}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {FE2B3EC2-1CB6-498D-A1A8-CEA8B79D8F2E}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU 20 | {FE2B3EC2-1CB6-498D-A1A8-CEA8B79D8F2E}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU 21 | {FE2B3EC2-1CB6-498D-A1A8-CEA8B79D8F2E}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU 22 | {FE2B3EC2-1CB6-498D-A1A8-CEA8B79D8F2E}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU 23 | {FD83ED8E-0164-4F9A-A705-D7EE074CCE35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {FD83ED8E-0164-4F9A-A705-D7EE074CCE35}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {FD83ED8E-0164-4F9A-A705-D7EE074CCE35}.ExportDebug|Any CPU.ActiveCfg = ExportDebug|Any CPU 26 | {FD83ED8E-0164-4F9A-A705-D7EE074CCE35}.ExportDebug|Any CPU.Build.0 = ExportDebug|Any CPU 27 | {FD83ED8E-0164-4F9A-A705-D7EE074CCE35}.ExportRelease|Any CPU.ActiveCfg = ExportRelease|Any CPU 28 | {FD83ED8E-0164-4F9A-A705-D7EE074CCE35}.ExportRelease|Any CPU.Build.0 = ExportRelease|Any CPU 29 | EndGlobalSection 30 | GlobalSection(SolutionProperties) = preSolution 31 | HideSolutionNode = FALSE 32 | EndGlobalSection 33 | EndGlobal 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Dangles 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |

ControlBinding

4 |

A WPF-style control binding implementation for Godot Mono

5 |
6 | 7 | 8 | ## :package: Packages 9 | 17 | 18 | ## Demo 19 |
20 | :clapper: Movie 21 | 22 | 23 | 24 | https://github.com/Dangles91/Godot.Community.ControlBinding/assets/9249458/91295e19-91cd-4eb8-adf1-1798cae6b532 25 | 26 | 27 | 28 |
29 | 30 | ## :train: Further development 31 | Though functional, this project is in the early stages of development. More advanced features could still yet be developed, including: 32 | * [x] Binding control children 33 | * [x] Instantiate scenes as control children 34 | * [x] Control validation 35 | * [ ] Control style formatting 36 | * [ ] Creating an editor plugin to specify bindings in the editor 37 | * [x] Code generation to implement OnPropertyChanged via an attribute decorator 38 | 39 | > Source generators such as [PropertyBinding.SourceGenerator](https://github.com/canton7/PropertyChanged.SourceGenerator) can be used to implement `INotifyPropertyChanged` 40 | 41 | ## Overview 42 | Godot.Community.ControlBinding implements control bindings using Microsofts System.ComponentModel `INotifyPropertyChanged` and `INotifyCollectionChanged` interfaces. 43 | 44 | ## :dart: Features 45 | ### Property binding 46 | Simple property binding from Godot controls to C# properties 47 | 48 | ### List binding 49 | Bind list controls to an `ObservableCollection`. List bindings support `OptionButton` and `ItemList` controls. 50 | If the list objects implement `INotifyPropertyChanged` the controls will be updated to reflect changes made to the backing list. 51 | 52 | ### Enum list binding 53 | A very specific list binding implementation to bind Enums to an OptionButton with support for a target property to store the selected option. 54 | ```csharp 55 | bindingContext.BindEnumProperty(GetNode("%OptionButton"), $"{nameof(SelectedPlayerData)}.BindingMode"); 56 | ``` 57 | 58 | ### Two-way binding 59 | Some controls support two-way binding by subscribing to their update signals for supported properties. 60 | Supported properties: 61 | - LineEdit.Text 62 | - TextEdit.Text 63 | - CodeEdit.Text 64 | - Slider.Value, Progress.Value, SpinBox.Value, and ScrollBar.Value 65 | - CheckBox.ButtonPressed 66 | - OptionButton.Selected 67 | 68 | ### Automatic type conversion 69 | Automatic type conversion for most common types. Eg, binding string value "123" to int 70 | 71 | ### Custom formatters 72 | Specify a custom `IValueFormatter` to format/convert values to and from the bound control and target property 73 | 74 | ### Custom list item formatters 75 | List items can be further customised during binding by implementing a custom `IValueFormatter` that returns a `ListItem` with the desired properties 76 | 77 | ### Deep binding 78 | Binding to target properties is implemented using a path syntax. eg. `MyClass.MyClassName` will bind to the `MyClassName` property on the `MyClass` object. 79 | 80 | ### Automatic rebinding 81 | If any objects along the path are updated, the binding will be refreshed. Objects along the path must inherit from `ObservableObject` or implement `INotifyPropertyChanged`. 82 | 83 | ### Scene list binding 84 | Bind an `ObservableCollection` to any control and provide a scene to instiate as child nodes. Modifications (add/remove) are reflected in the control's child list. 85 | 86 | Scene list bindings have limited TwoWay binding support. Child items removed from the tree will also be removed from the bound list. 87 | 88 | ![scenelist](https://github.com/Dangles91/Godot.Community.ControlBinding/assets/9249458/eb5c04d3-c938-4424-bc35-48a43e272e79) 89 | 90 | ## :toolbox: Usage 91 | The main components of control binding are the `ObservableObject`, `ControlViewModel`, and `NodeViewModel` classes which implement `INotifyPropertyChanged`. These classes are included for ease of use, but you can inherit from your own base classes which implement `INotifyPropertyChanged` or use source generators to implement this interface instead. 92 | 93 | The script which backs your scene must implement `INotifyPropertyChanged`. 94 | 95 | Bindings are registered against a `BindingContext` instance. This also provides support for input validation. 96 | 97 | See the ![example project](/examples/basic-bindings) for some bindings in action! 98 | 99 | ### Property binding 100 | Create a property with a backing field and trigger `OnPropertyChanged` in the setter 101 | 102 | ```c# 103 | private int spinBoxValue; 104 | public int SpinBoxValue 105 | { 106 | get { return spinBoxValue; } 107 | set { spinBoxValue = value; OnPropertyChanged(); } 108 | } 109 | 110 | ``` 111 | 112 | Alternatively, use the `SetValue` method to update the backing field and trigger `OnPropertyChanged` 113 | 114 | ```c# 115 | private int spinBoxValue; 116 | public int SpinBoxValue 117 | { 118 | get { return spinBoxValue; } 119 | set { SetValue(ref spinBoxValue, value); } 120 | } 121 | ``` 122 | 123 | Or use a source generator instead 124 | ```c# 125 | [Notify] private int _spinBoxValue; 126 | 127 | ``` 128 | 129 | Add a binding in `_Ready()`. This binding targets a control in the scene with the unique name **%SpinBox** with the `BindingMode` __TwoWay__. A BindingMode of TwoWay states that we want the spinbox value to be set into the target property and vice-versa. 130 | 131 | ```c# 132 | public override void _Ready() 133 | { 134 | BindingContext bindingContext = new(this); 135 | bindingContext.BindProperty(GetNode("%SpinBox"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay); 136 | 137 | // alternatively, use the extensions methods 138 | GetNode("%SpinBox").BindProperty(bindingContext, nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay); 139 | } 140 | 141 | ``` 142 | 143 | ### Deep property binding 144 | Bind to property members on other objects. These objects and properties must be relative to the current scene script. 145 | 146 |
147 | details 148 | 149 | ```c# 150 | // Bind to SelectedPlayerData.Health 151 | bindingContext.BindProperty(GetNode("%LineEdit"), nameof(LineEdit.Text), $"{nameof(SelectedPlayerData)}.{nameof(PlayerData.Health)}", BindingMode.TwoWay); 152 | 153 | // Alternatively represent this as a string path instead 154 | bindingContext.BindProperty(GetNode("%LineEdit"), nameof(LineEdit.Text), "SelectedPlayerData.Health", BindingMode.TwoWay); 155 | 156 | ``` 157 | 158 | The property `SelectedPlayerData` must notify about changes to automatically rebind the control. TwoWay binding also requires that the PlayerData class implements `INotifyPropertyChanged` to notify of property changes. 159 | ```c# 160 | private PlayerData selectedPlayerData = new(); 161 | public PlayerData SelectedPlayerData 162 | { 163 | get { return selectedPlayerData; } 164 | set { SetValue(ref selectedPlayerData, value); } 165 | } 166 | ``` 167 |
168 | 169 | ### Formatters 170 | A binding can be declared with an optional formatter to format the value between your control and the target property or implement custom type conversion. Formatters can also be used to modify list items properties by returning a `ListItem` object. 171 | 172 | Formatter also have access to the target property value. In the example below, the `v` parameter is the value from the source property and `p` is the value of the target property. 173 | 174 |
175 | details 176 | 177 | ```c# 178 | public class PlayerHealthFormatter : IValueFormatter 179 | { 180 | public Func FormatControl => (v, p) => 181 | { 182 | return $"Player health: {v}"; 183 | }; 184 | 185 | public Func FormatTarget => (v, p) => 186 | { 187 | throw new NotImplementedException(); 188 | }; 189 | } 190 | 191 | bindingContext.BindProperty(GetNode("%SpinBox"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay, new PlayerHealthFormatter()); 192 | ``` 193 | 194 | This formatter will set a string value into the target control using the input value substituted into a string. `FormatControl` is not implemented here so the value would be passed back as-is in the case of a two-way binding. 195 | 196 |
197 | 198 | ### List Binding 199 | List bindings can be bound to an `ObservableCollection` (or any data structure that implements `INotifyCollectionChanged`) to benefit from adding and removing items 200 | 201 |
202 | details 203 | 204 | ```c# 205 | public ObservableList PlayerDatas {get;set;} = new(){ 206 | new PlayerData{Health = 500}, 207 | }; 208 | 209 | bindingContext.BindListProperty(GetNode("%ItemList2"), nameof(PlayerDatas), formatter: new PlayerDataListFormatter()); 210 | ``` 211 | 212 | The `PlayerDataListFormatter` formats the PlayerData entry into a usable string value using a `ListItem` to also provided conditional formatting to the control 213 | 214 | ```c# 215 | public class PlayerDataListFormatter : IValueFormatter 216 | { 217 | public Func FormatControl => (v) => 218 | { 219 | var pData = v as PlayerData; 220 | var listItem = new ListItem 221 | { 222 | DisplayValue = $"Health: {pData.Health}", 223 | Icon = ResourceLoader.Load("uid://bfdb75li0y86u"), 224 | Disabled = pData.Health < 1, 225 | Tooltip = pData.Health == 0 ? "Health must be greater than 0" : null, 226 | 227 | }; 228 | return listItem; 229 | }; 230 | 231 | public Func FormatTarget => throw new NotImplementedException(); 232 | } 233 | ``` 234 | 235 |
236 | 237 | ### Scene List Binding 238 | Bind an `ObservableCollection` to a control's child list to add/remove children. The target scene must have a script attached and implement `IViewModel`, which inherits from `INotifyPropertyChanged`. It must also provide an implementation for `SetViewModeldata()` from the `IViewModel` interface. 239 | 240 |
241 | details 242 | 243 | **Bind the control to a list and provide a path to the scene to instiate** 244 | ```c# 245 | bindingContext.BindSceneList(GetNode("%VBoxContainer"), nameof(PlayerDatas), "uid://die1856ftg8w8"); 246 | ``` 247 | 248 | **Scene implementation** 249 | ```c# 250 | public partial class PlayerDataListItem : ObservableNode 251 | { 252 | private PlayerData ViewModelData { get; set; } 253 | 254 | public override void SetViewModelData(object viewModelData) 255 | { 256 | ViewModelData = viewModelData as PlayerData; 257 | base.SetViewModelData(viewModelData); 258 | } 259 | 260 | public override void _Ready() 261 | { 262 | BindingContext bindingContext = new(this); 263 | bindingContext.BindProperty(GetNode("%TextEdit"), "Text", "ViewModelData.Health", BindingMode.TwoWay); 264 | base._Ready(); 265 | } 266 | } 267 | ``` 268 |
269 | 270 | ### Control input validation 271 | Control bindings can be validated by either: 272 | * Adding validation function to the binding 273 | * Throwing a `ValidationException` from a formatter 274 | 275 | There also two main ways of subscribing to validation changed events: 276 | * Subscribe to the `ControlValidationChanged` event on the `BindingContext` your bindings reside on 277 | * Add a validation handler to the control binding 278 | 279 | You can also use the `HasErrors` property on a `BindingContext` to notify your UI of errors and review a full list of validation errors using the `GetValidationMessages()` method. 280 | 281 | 282 |
283 | details 284 |
285 | 286 | **Adding validators and validation callbacks** 287 | 288 | Property bindings implement a fluent builder pattern for modify the binding upon creation to add validators and a validator callback. 289 | 290 | You can have any number of validators but only one validation callback. 291 | 292 | Validators are run the in the order they are registered and validation will stop at the first validator to return a non-empty string. Validators are run before formatters. The formatter will not be executed if a validation error occurs. 293 | 294 | This example adds two validators and a callback to modulate the control and set the tooltip text. 295 | 296 | ```c# 297 | bindingContext.BindProperty(GetNode("%LineEdit"), nameof(LineEdit.Text), $"{nameof(SelectedPlayerData)}.{nameof(PlayerData.Health)}", BindingMode.TwoWay) 298 | .AddValidator(v => int.TryParse((string)v, out int value) ? null : "Health must be a number") 299 | .AddValidator(v => int.TryParse((string)v, out int value) && value > 0 ? null : "Health must be greater than 0") 300 | .AddValidationHandler((control, isValid, message) => { 301 | control.Modulate = isValid ? Colors.White : Colors.Red; 302 | control.TooltipText = message; 303 | }); 304 | ``` 305 | 306 | **Subscribing to `ControlValidationChanged` events** 307 | 308 | If you want to have common behaviour for many or all controls, you can subscribe to the `ControlValidationChanged` event and get updates about all control validations. 309 | 310 | This example subscribes to all validation changed events to modulate the target control and set the tooltip text. 311 | 312 | The last validation error message is also stored in the local ErrorMessage property to be bound to a UI label. 313 | 314 | ```csharp 315 | public partial class MyClass : ObservableNode 316 | { 317 | private string errorMessage; 318 | public string ErrorMessage 319 | { 320 | get { return errorMessage; } 321 | set { SetValue(ref errorMessage, value); } 322 | } 323 | 324 | public override void _Ready() 325 | { 326 | BindingContext bindingContext = new(this); 327 | bindingContext.BindProperty(GetNode("%ErrorLabel"), nameof(Label.Visible), $"{nameof(bindingContext)}.{nameof(HasErrors)}", BindingMode.OneWay); 328 | bindingContext.BindProperty(GetNode("%ErrorLabel"), nameof(Label.Text), nameof(ErrorMessage), BindingMode.OneWay); 329 | bindingContext.ControlValidationChanged += OnControlValidationChanged; 330 | } 331 | 332 | private void OnControlValidationChanged(control, propertyName, message, isValid) 333 | { 334 | control.Modulate = isValid ? Colors.White : Colors.Red; 335 | control.TooltipText = message; 336 | 337 | // set properties to bind to 338 | ErrorMessage = message; 339 | ValidationSummary = GetValidationMessages(); 340 | }; 341 | } 342 | ``` 343 | 344 |
345 | -------------------------------------------------------------------------------- /examples/basic-bindings/.gitignore: -------------------------------------------------------------------------------- 1 | .godot 2 | .mono 3 | .vscode -------------------------------------------------------------------------------- /examples/basic-bindings/Control.cs: -------------------------------------------------------------------------------- 1 | using Godot; 2 | using Godot.Community.ControlBinding; 3 | using Godot.Community.ControlBinding.Extensions; 4 | using Godot.Community.ControlBinding.Formatters; 5 | using Godot.Community.ControlBinding.Interfaces; 6 | using PropertyChanged.SourceGenerator; 7 | using System.Collections.ObjectModel; 8 | 9 | namespace ControlBinding; 10 | 11 | public partial class Control : Godot.Control, IObservableObject 12 | { 13 | [Notify] 14 | private bool _labelIsVisible = true; 15 | 16 | [Notify] 17 | private bool _isAddNewPlayerEnabled = true; 18 | 19 | [Notify] 20 | private string _longText; 21 | 22 | [Notify] 23 | private int _spinBoxValue; 24 | 25 | public ObservableCollection PlayerDatas { get; set; } = new(){ 26 | new PlayerData{Health = 500}, 27 | }; 28 | 29 | public ObservableCollection PlayerDatas2 { get; set; } = new(){ 30 | new PlayerData{Health = 500}, 31 | }; 32 | 33 | [Notify] private BindingMode _selectedBindingMode; 34 | [Notify] private ObservableCollection _backinglistForTesting = new() { "Test" }; 35 | [Notify] private string _errorMessage; 36 | [Notify] private string _customControlInput; 37 | 38 | private BindingContext bindingContext; 39 | 40 | public override void _Ready() 41 | { 42 | bindingContext = new BindingContext(this); 43 | 44 | // Bind root properties to UI 45 | bindingContext.BindProperty(GetNode("%Button"), nameof(Button.Disabled), nameof(IsAddNewPlayerEnabled), formatter: new ReverseBoolValueFormatter()); 46 | GetNode("%CodeEdit").BindProperty(bindingContext, nameof(CodeEdit.Text), nameof(LongText), BindingMode.TwoWay); 47 | 48 | bindingContext.BindProperty(GetNode("%CodeEdit2"), nameof(CodeEdit.Text), nameof(LongText), BindingMode.TwoWay); 49 | bindingContext.BindProperty(GetNode("%CheckBox"), nameof(CheckBox.ButtonPressed), nameof(IsAddNewPlayerEnabled), BindingMode.TwoWay); 50 | 51 | bindingContext.BindProperty(GetNode("%HSlider"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay); 52 | bindingContext.BindProperty(GetNode("%VSlider"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay); 53 | bindingContext.BindProperty(GetNode("%SpinBox"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay) 54 | .AddValidator(v => (double)v > 0f ? null : "Value must be greater than 0"); 55 | 56 | bindingContext.BindProperty(GetNode("%HScrollBar"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay); 57 | bindingContext.BindProperty(GetNode("%VScrollBar"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay); 58 | bindingContext.BindProperty(GetNode("%ProgressBar"), nameof(SpinBox.Value), nameof(SpinBoxValue), BindingMode.TwoWay); 59 | bindingContext.BindProperty(GetNode("%SpinboxLabel"), nameof(Label.Text), nameof(SpinBoxValue), BindingMode.OneWay); 60 | 61 | // Bind to SelectedPlayerData.Health 62 | bindingContext.BindProperty(GetNode("%LineEdit"), nameof(LineEdit.Text), $"{nameof(SelectedPlayerData)}.{nameof(PlayerData.Health)}", BindingMode.TwoWay, 63 | new ValueFormatter() 64 | { 65 | FormatTarget = (v, p) => int.TryParse((string)v, out int value) ? value : 0, 66 | }) 67 | .AddValidator(v => int.TryParse((string)v, out int value) ? null : "Health must be a number") 68 | .AddValidator(v => int.TryParse((string)v, out int value) && value > 0 ? null : "Health must be greater than 0") 69 | .AddValidationHandler((control, isValid, message) => 70 | { 71 | (control as LineEdit).RightIcon = isValid ? null : (Texture2D)ResourceLoader.Load("uid://b5s5nstqwi4jh"); 72 | (control as LineEdit).Modulate = new Color(1, 1, 1, 1); 73 | }); 74 | 75 | // list binding 76 | bindingContext.BindListProperty(GetNode("%ItemList"), nameof(PlayerDatas), formatter: new PlayerDataListFormatter()); 77 | bindingContext.BindListProperty(GetNode("%ItemList2"), nameof(PlayerDatas2), formatter: new PlayerDataListFormatter()); 78 | 79 | bindingContext.BindProperty(GetNode("%TextEdit"), nameof(TextEdit.Text), $"{nameof(SelectedPlayerData)}.{nameof(PlayerData.ListOfThings)}", BindingMode.OneWayToTarget, new StringToListFormatter()); 80 | bindingContext.BindListProperty(GetNode("%ItemList3"), $"{nameof(SelectedPlayerData)}.{nameof(PlayerData.ListOfThings)}", BindingMode.TwoWay); 81 | 82 | bindingContext.BindEnumProperty(GetNode("%OptionButton"), $"{nameof(SelectedPlayerData)}.BindingMode"); 83 | bindingContext.BindSceneList(GetNode("%VBoxContainer"), nameof(PlayerDatas), "uid://die1856ftg8w8", BindingMode.TwoWay); 84 | bindingContext.BindProperty(GetNode("%ErrorLabel"), nameof(Visible), $"{nameof(bindingContext)}.{nameof(bindingContext.HasErrors)}", BindingMode.OneWay); 85 | bindingContext.BindProperty(GetNode("%ErrorLabel"), nameof(Label.Text), nameof(ErrorMessage), BindingMode.OneWay); 86 | 87 | // Binding a custom control 88 | bindingContext.BindProperty(GetNode("%CustomControl"), nameof(CustomControl.Input), nameof(CustomControlInput), BindingMode.TwoWay); 89 | bindingContext.BindProperty(GetNode("%CustomInput"), nameof(Label.Text), nameof(CustomControlInput), BindingMode.OneWay); 90 | 91 | bindingContext.ControlValidationChanged += (control, propertyName, message, isValid) => 92 | { 93 | control.Modulate = isValid ? Colors.White : Colors.Red; 94 | control.TooltipText = message; 95 | ErrorMessage = message; 96 | }; 97 | 98 | // Connect some buttons to test collection modifications 99 | GetNode