1
0
Fork 0
mirror of https://github.com/Ellpeck/MLEM.git synced 2024-11-29 15:58:33 +01:00

Compare commits

..

135 commits

Author SHA1 Message Date
Ell
30160e8210 release 6.1.0 2023-01-19 20:48:44 +01:00
Ell
eb8a8568e1 Some ui system improvements
- Allow initializing a ui system's text formatter without default codes and macros
- Fixed UiStyle.LinkColor not being applied to the ui system when changed
2023-01-08 11:21:20 +01:00
Ell
a249331ce9 Allow initializing text formatters without default codes and macros 2023-01-07 22:32:38 +01:00
Ell
500736e20f Added subscript and superscript formatting codes 2023-01-07 22:02:22 +01:00
Ell
2e8a8244a3 Added RandomExtensions.NextSingle with minimum and maximum values 2023-01-07 20:01:22 +01:00
Ell
04829f1695 added the android demo project back to the solution 2023-01-03 14:34:52 +01:00
Ell
4bdecf792e submodule update 2022-12-28 16:46:52 +01:00
Ell
ef0499958d - Added InputHandler.IsPressedIgnoreRepeats
- Marked non-GenericInput versions of IsDown, IsUp, IsPressed and related methods as obsolete in favor of GenericInput ones
2022-12-27 22:57:35 +01:00
Ell
79a2eaa8d2 Added InputHandler.WasPressedForLess and related methods 2022-12-26 18:44:46 +01:00
Ell
10bea0f820 improved changelog slightly 2022-12-23 17:08:51 +01:00
Ell
f8ebbdacdf fixed text not being checked for changes anymore when set explicitly (since f8567cf) 2022-12-23 15:08:40 +01:00
Ell
f5be677b83 update a paragraph's tokenized text before updating its children and event 2022-12-23 14:54:02 +01:00
Ell
d6ab8061f3 fixed new paragraph handling not checking for changes before calculating size 2022-12-23 14:36:31 +01:00
Ell
179afbc428 made sure that all element changes mark their data dirty correctly 2022-12-23 13:25:56 +01:00
Ell
f5ff96d348 Fixed ee62554 not working when the padding or scale changes 2022-12-22 20:04:38 +01:00
Ell
ee62554fee Avoid paragraphs splitting or truncating their text unnecessarily 2022-12-22 19:50:50 +01:00
Ell
f8567cfc99 Cleaned up Paragraph code and ensured that TokenizedText never returns null 2022-12-22 19:18:33 +01:00
Ell
73abfb2dc3 Added TextField.OnCopyPasteException to allow handling exceptions thrown by TextCopy 2022-12-22 11:39:07 +01:00
Ell
df2b9cc10e avoid setting underlying values for ui element callbacks 2022-12-21 21:47:49 +01:00
Ell
45c668c992 Removed AutoHideCondition (partially reverts d5d3297) 2022-12-21 21:02:10 +01:00
Ell
d5d3297271 Added Element.AutoHideCondition and Button.AutoDisableCondition 2022-12-21 18:54:25 +01:00
Ell
b41341d01e Set cornflower blue as the default link color 2022-12-18 13:01:19 +01:00
Ell
b3da8d35c0 Ensure paragraphs display up-to-date versions of their text callbacks 2022-12-16 20:03:19 +01:00
Ell
14d0b24aa9 cleaned up preprocessor instructions 2022-12-13 13:11:36 +01:00
Ell
e229432d0f updated docfx and removed analytics from docs site 2022-12-12 10:28:43 +01:00
Ell
4189ae6d4d dependency update
Closes #6 and #7 (both of which were incomplete)
2022-12-08 17:02:22 +01:00
Ell
170b397e02 Cleaned up GenericFont and TokenizedString by improving the splitting and truncating algorithms 2022-12-07 13:35:57 +01:00
Ell
f0b65daf68 fixed GetSelfWidth receiving the default and custom font inconsistently 2022-12-06 17:49:38 +01:00
Ell
066ed9f8f7 fixed some issues with the new code width system 2022-12-06 17:10:04 +01:00
Ell
b374d50815 Allow formatting codes to have an arbitrary custom width 2022-12-06 16:49:19 +01:00
Ell
cf3d0e8e0c added some more Add overloads to SeedSource 2022-12-04 22:43:25 +01:00
Ell
943616e21a moved SeedSource to its own file and added Rotate 2022-11-30 23:11:37 +01:00
Ell
e3b41ebeea improved SeedSource algorithm to be less predictable 2022-11-30 21:31:34 +01:00
Ell
0a62ae0036 Added SeedSource 2022-11-30 20:21:24 +01:00
Ell
8ffd9ab48a improved and cleaned up SingleRandom 2022-11-30 00:24:43 +01:00
Ell
edcaa84a2b ensure that SingleRandom seeds are never zero 2022-11-29 23:56:34 +01:00
Ell
8555fc2499 updated SingleRandom signatures to avoid confusions when calling 2022-11-29 21:36:17 +01:00
Ell
88efc6b41c fixed Single min/max values being integers 2022-11-29 21:29:53 +01:00
Ell
b2b4dfbdc9 also added SingleRandom ranges for floats 2022-11-29 21:21:50 +01:00
Ell
81c69041c3 Added the SingleRandom utility class 2022-11-29 21:11:59 +01:00
Ell
1a1b2025cd Made JsonTypeSafeWrapper.Of generic to potentially avoid reflective instantiation 2022-11-28 00:43:50 +01:00
Ell
d72b094a7a Added a generic version of IGenericDataHolder.SetData 2022-11-27 12:34:07 +01:00
Ell
e5cfebef3b cleaned up element addition/removal code 2022-11-24 19:46:20 +01:00
Ell
e21729de67 fixed some memory management issues in MLEM.Ui 2022-11-24 18:38:51 +01:00
Ell
9919ee4a97 added the ability to convert texture atlases to collections 2022-11-23 12:14:08 +01:00
Ell
92353e40e6 Allow adding JsonTypeSafeWrapper instances to JsonTypeSafeGenericDataHolder directly 2022-11-22 21:51:42 +01:00
Ell
e812dd7802 fixed JsonTypeSafeWrapper Value not being ignored when serializing 2022-11-22 20:43:11 +01:00
Ell
3b08b66fa4 removed outdated test 2022-11-22 20:21:25 +01:00
Ell
63ea3eba90 DataContract usage improvements 2022-11-22 20:09:12 +01:00
Ell
810406fb94 Added the ability for UniformTextureAtlases to have padding for each region 2022-11-19 17:12:57 +01:00
Ell
b5619db55c Allow explicitly specifying each region for extended auto tiles 2022-11-14 12:59:35 +01:00
Ell
a1df35ea05 also set source in OffsetCopy 2022-11-14 11:51:29 +01:00
Ell
db02dfcfde Store a RuntimeTexturePacker packed texture region's source region 2022-11-14 11:49:47 +01:00
Ell
6ac2ba6151 Added Range extension methods GetPercentage and FromPercentage 2022-11-10 15:41:24 +01:00
Ell
5906278091 Fixed Combination.IsModifierDown querying one of its modifiers instead of all of them 2022-11-08 17:50:58 +01:00
Ell
8bb62a2ce5 also added WasModifierDown and WasDown to Keybind and Combination 2022-11-08 17:43:34 +01:00
Ell
797a3b2617 Improved the way InputHandler down time calculation works 2022-11-08 17:12:37 +01:00
Ell
8469185297 Added GetDownTime, GetUpTime and GetTimeSincePress to Keybind and Combination 2022-11-08 16:43:32 +01:00
Ell
cc749103e0 Allow specifying percentage-based padding for a NinePatch 2022-11-03 18:16:59 +01:00
Ell
2df9d6a3a8 added DynamicEnums to Friends of MLEM list 2022-10-31 18:57:10 +01:00
Ell
d138577285 Added trimming and AOT annotations and made MLEM trimmable 2022-10-31 18:33:53 +01:00
Ell
f58e3c94d5 Marked EnumHelper and DynamicEnum as obsolete due to their reimplementation in the DynamicEnums library 2022-10-31 13:20:26 +01:00
Ell
791c66b098 code cleanup 2022-10-27 10:22:25 +02:00
Ell
8745a3237e Added DynamicEnum IsDefined and EnumHelper and DynamicEnum GetUniqueFlags 2022-10-26 23:34:30 +02:00
Ell
7ab76d239d Added EnumHelper and DynamicEnum GetFlags 2022-10-26 15:02:33 +02:00
Ell
d5e5d1d536 moved the new ConvertCachedUtf32 to CodePointSource 2022-10-23 21:23:16 +02:00
Ell
be7676d37e fixed unnecessary memory allocations since 8d689952cc 2022-10-22 11:46:11 +02:00
Ell
17ce7b668d Added the ability to add additional regions to a RuntimeTexturePacker after packing 2022-10-20 23:59:42 +02:00
Ell
627350ca31 Added ElementHelper.MakeGrid 2022-10-17 10:57:41 +02:00
Ell
d3fade27e5 changed local CI nuget source 2022-10-15 14:04:45 +02:00
Ell
8d689952cc Made GenericFont and TokenizedString support UTF-32 characters like emoji 2022-10-15 13:48:45 +02:00
Ell
560c797b87 Fixed InputHandler and UiControls maintaining old input states when input types are toggled off 2022-10-10 19:29:01 +02:00
Ell
d6309ce9c1 Added the ability to find paths to one of multiple goals using AStar 2022-10-10 11:31:23 +02:00
Ell
d3b153fd45 Cleaned up AStar code 2022-10-09 22:38:10 +02:00
Ell
0b6e6743cf some more AStar improvements 2022-10-09 21:04:39 +02:00
Ell
3e4c4e566d Merge remote-tracking branch 'origin/main' 2022-10-09 20:07:51 +02:00
Ell
bfa4ab4ac2 Added the ability to include special per-position directions in AStar pathfinding 2022-10-09 20:07:38 +02:00
Ell
92f9164256 Added Panel.ScrollToElement 2022-09-24 18:46:33 +02:00
Ell
d6a51776e5 Fixed the scroll bar of an empty panel being positioned incorrectly 2022-09-24 11:04:23 +02:00
Ell
74b3c426b8 readme updates 2022-09-22 17:19:22 +02:00
Ell
e8710f69e9 Fixed an exception when trying to force-update the area of an element without a ui system 2022-09-19 15:02:36 +02:00
Ell
02cf01fcb7 added Append and Prepend to the net452 version for better code compatibility 2022-09-15 17:51:46 +02:00
Ell
fe89b28031 publish MG and FNA test results separately 2022-09-15 12:50:10 +02:00
Ell
9f60a59706 resolved some build warnings 2022-09-15 10:44:50 +02:00
Ell
d0500bf981 reference solutions for tests in build.cake 2022-09-14 23:44:07 +02:00
Ell
e6b4bc54ba exclude the android project from build 2022-09-14 23:28:04 +02:00
Ell
b6a626d96e use FNA core for tests and demos 2022-09-14 22:13:05 +02:00
Ell
39a7dd3e97 updated build script 2022-09-14 22:00:45 +02:00
Ell
53cda02ec4 fixed some issues with the FNA project 2022-09-14 21:53:25 +02:00
Ell
48735c3d36 Multi-target net452, making MLEM compatible with MonoGame for consoles 2022-09-14 21:17:43 +02:00
Ell
fc026ad0de multi-target netstandard2.0 and net6.0 2022-09-14 19:24:00 +02:00
Ell
c3c8b132da fixed DataTextureAtlas frm instruction failing if the original texture has an offset 2022-09-14 12:19:05 +02:00
Ell
7d8b14ee8d added from instruction to DataTextureAtlas 2022-09-14 11:59:28 +02:00
Ell
ff92a00e1a FNA doesn't support vector offsets 2022-09-14 11:24:45 +02:00
Ell
740c65a887 fixed new cpy instruction yielding incorrect pivots 2022-09-14 11:20:55 +02:00
Ell
4918a72760 fixed some issues with the new data texture atlas parser 2022-09-14 11:04:51 +02:00
Ell
914b0d9c2d - Improved DataTextureAtlas parsing
- Added data and copy instructions to DataTextureAtlas
2022-09-14 10:40:52 +02:00
Ell
38214a66a3 disable mouse drag scrolling in the ui demo 2022-09-13 16:15:59 +02:00
Ell
a6fd2c052e Added ScrollBar.MouseDragScrolling
Closes #5
2022-09-13 16:14:36 +02:00
Ell
d0c805cf18 Fixed Element.OnChildAdded and Element.OnChildRemoved being called for grandchildren when a child is added 2022-09-13 15:44:12 +02:00
Ell
55735b4c64 Added Element.OnAddedToUi and Element.OnRemovedFromUi 2022-09-13 14:27:49 +02:00
Ell
df2d102d8e further improved StaticSpriteBatch performance 2022-09-13 11:57:28 +02:00
Ell
b4e1b00c88 finished static sprite batch optimizations 2022-09-12 23:51:12 +02:00
Ell
eadabf3919 use SortedDictionary for StaticSpriteBatch 2022-09-12 23:09:36 +02:00
Ell
856d67b6cf Second pass at StaticSpriteBatch optimizations 2022-09-12 22:57:01 +02:00
Ell
742bc52437 First pass at drastically improving StaticSpriteBatch batching performance 2022-09-12 21:51:21 +02:00
Ell
d6e7c1086d Discard old data when updating a StaticSpriteBatch 2022-09-12 21:13:43 +02:00
Ell
e60d3591ff updated tests to net6.0 2022-09-09 17:55:23 +02:00
Ell
a274517861 fixed non-MLEM packages being deleted in build 2022-09-09 17:55:14 +02:00
Ell
d94e882c02 added markdown editorconfig settings 2022-09-09 11:17:37 +02:00
Ell
2d3d93c610 Generified UiMarkdownParser by adding abstract UiParser 2022-09-04 12:26:55 +02:00
Ell
963ea557e8 updated dependencies 2022-09-03 12:31:34 +02:00
Ell
5e68bf5d3d fixed docfx finding duplicates due to FNA projects 2022-09-02 16:37:59 +02:00
Ell
023233a062 Fixed some TokenizedString tokens starting with a line break not being split correctly 2022-09-02 14:07:23 +02:00
Ell
0fe4c940d7 Fixed TokenizedString handling trailing spaces incorrectly in the last line of non-left aligned text 2022-09-02 13:58:12 +02:00
Ell
32dad847a0 adAdded TokenizedString.Realign 2022-09-02 13:42:21 +02:00
Ell
1f2e2a4f38 updated FNA submodule 2022-08-20 11:47:02 +02:00
Ell
0a696941dc cleaned up code 2022-08-20 11:39:28 +02:00
Ell
6a271af017 Fixed UiMarkdownParser not parsing formatting in headings and blockquotes 2022-08-19 17:57:44 +02:00
Ell
af0aee6c40 Added Element.AutoSizeAddedAbsolute to allow for more granular control of auto-sizing 2022-08-16 14:20:32 +02:00
Ell
e50d28ce11 Allow using external gesture handling alongside InputHandler through ExternalGestureHandling 2022-08-11 11:37:41 +02:00
Ell
f0432ab981 Fixed panels sometimes not drawing children that came into view when their positions changed unexpectedly 2022-08-04 21:03:16 +02:00
Ell
8332f56237 improved changelog md formatting 2022-08-04 20:44:54 +02:00
Ell
b7b1490d70 Fixed paragraphs sometimes not updating their position properly when hidden because they're empty 2022-08-04 20:43:04 +02:00
Ell
4d34a2fac1 Fixed parents of elements that prevent spill not being notified properly 2022-08-04 20:14:29 +02:00
Ell
b2898a8eae Made RuntimeTexturePacker restore texture region name and pivot when packing 2022-08-03 10:37:59 +02:00
Ell
b012c65990 Allow data texture atlas pivots and offsets to be negative 2022-08-02 23:56:18 +02:00
Ell
72647a2edf Fixed data texture atlases not allowing most characters in their region names 2022-08-02 23:02:34 +02:00
Ell
02b4626996 disable smooth scrolling in the ui demo 2022-07-29 22:33:32 +02:00
Ell
5aaba0c583 Close other dropdowns when opening a dropdown 2022-07-29 22:24:37 +02:00
Ell
8044cb59cb Improved EnumHelper.GetValues signature to return an array 2022-07-29 19:52:01 +02:00
Ell
7a0464e8d6 fixed GetRightmostChild using the wrong variable for calculation 2022-07-27 11:52:28 +02:00
Ell
87d04e1abd updated tests for element changes 2022-07-27 11:34:52 +02:00
Ell
f0cc4b0c80 Allow elements to auto-adjust their size even when their children are aligned oddly 2022-07-27 11:19:40 +02:00
Ell
b78465c054 bump upcoming version 2022-07-25 18:56:21 +02:00
133 changed files with 24510 additions and 2228 deletions

View file

@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"cake.tool": {
"version": "1.3.0",
"version": "3.0.0",
"commands": [
"dotnet-cake"
]

View file

@ -87,8 +87,9 @@ resharper_indent_nested_for_stmt = true
resharper_indent_nested_lock_stmt = true
resharper_indent_nested_usings_stmt = true
resharper_indent_nested_while_stmt = true
resharper_indent_preprocessor_if = usual_indent
resharper_indent_preprocessor_other = usual_indent
resharper_indent_preprocessor_if = no_indent
resharper_indent_preprocessor_other = no_indent
resharper_indent_preprocessor_region = no_indent
resharper_keep_existing_declaration_parens_arrangement = false
resharper_keep_existing_embedded_arrangement = false
resharper_keep_existing_expr_member_arrangement = false
@ -111,3 +112,7 @@ resharper_wrap_object_and_collection_initializer_style = wrap_if_long
resharper_xmldoc_attribute_indent = align_by_first_attribute
resharper_xmldoc_attribute_style = on_single_line
resharper_xmldoc_pi_attribute_style = on_single_line
[*.md]
trim_trailing_whitespace = false
indent_size = 2

2
.gitignore vendored
View file

@ -4,4 +4,4 @@ obj
packages
*.user
tools
TestResults
TestResults*

View file

@ -1,14 +1,135 @@
# Changelog
MLEM tries to adhere to [semantic versioning](https://semver.org/). Breaking changes are written in **bold**.
MLEM tries to adhere to [semantic versioning](https://semver.org/). Potentially breaking changes are written in **bold**.
Jump to version:
- [6.1.0](#610)
- [6.0.0](#600)
- [5.3.0](#530)
- [5.2.0](#520)
- [5.1.0](#510)
- [5.0.0](#500)
## 6.1.0
### MLEM
Additions
- Added TokenizedString.Realign
- Added GetFlags and GetUniqueFlags to EnumHelper
- Added GetDownTime, GetUpTime, GetTimeSincePress, WasModifierDown and WasDown to Keybind and Combination
- Added the ability for UniformTextureAtlases to have padding for each region
- Added UniformTextureAtlas methods ToList and ToDictionary
- Added SingleRandom and SeedSource
- Added TokenizedString.GetArea
- Added InputHandler.WasPressedForLess and related methods as well as InputHandler.IsPressedIgnoreRepeats
- Added RandomExtensions.NextSingle with minimum and maximum values
- Added subscript and superscript formatting codes
- **Added the ability to find paths to one of multiple goals using AStar**
Improvements
- Improved EnumHelper.GetValues signature to return an array
- Allow using external gesture handling alongside InputHandler through ExternalGestureHandling
- Discard old data when updating a StaticSpriteBatch
- Multi-target net452, making MLEM compatible with MonoGame for consoles
- Allow retrieving the cost of a calculated path when using AStar
- Added trimming and AOT annotations and made MLEM trimmable
- Allow specifying percentage-based padding for a NinePatch
- Improved the way InputHandler down time calculation works
- Allow explicitly specifying each region for extended auto tiles
- Added a generic version of IGenericDataHolder.SetData
- Allow formatting codes to have an arbitrary custom width
- Allow initializing text formatters without default codes and macros
- **Drastically improved StaticSpriteBatch batching performance**
- **Made GenericFont and TokenizedString support UTF-32 characters like emoji**
Fixes
- Fixed TokenizedString handling trailing spaces incorrectly in the last line of non-left aligned text
- Fixed some TokenizedString tokens starting with a line break not being split correctly
- Fixed InputHandler maintaining old input states when input types are toggled off
- Fixed Combination.IsModifierDown querying one of its modifiers instead of all of them
Removals
- Removed DataContract attribute from GenericDataHolder
- Marked EnumHelper as obsolete due to its reimplementation in [DynamicEnums](https://www.nuget.org/packages/DynamicEnums)
- Marked Code.GetReplacementString as obsolete
- Marked TokenizedString.Measure as obsolete in favor of GetArea
- Marked non-GenericInput versions of IsDown, IsUp, IsPressed and related methods as obsolete in favor of GenericInput ones
### MLEM.Ui
Additions
- Added some extension methods for querying Anchor types
- Added Element.AutoSizeAddedAbsolute to allow for more granular control of auto-sizing
- Added Element.OnAddedToUi and Element.OnRemovedFromUi
- Added ScrollBar.MouseDragScrolling
- Added Panel.ScrollToElement
- Added ElementHelper.MakeGrid
- Added Button.AutoDisableCondition
Improvements
- Allow elements to auto-adjust their size even when their children are aligned oddly
- Close other dropdowns when opening a dropdown
- Generified UiMarkdownParser by adding abstract UiParser
- Multi-target net452, making MLEM compatible with MonoGame for consoles
- Added trimming and AOT annotations and made MLEM.Ui trimmable
- Ensure paragraphs display up-to-date versions of their text callbacks
- Set cornflower blue as the default link color
- Added TextField.OnCopyPasteException to allow handling exceptions thrown by TextCopy
- Avoid paragraphs splitting or truncating their text unnecessarily
- Automatically mark elements dirty when various member values are changed
- Allow initializing a ui system's text formatter without default codes and macros
Fixes
- Fixed parents of elements that prevent spill not being notified properly
- Fixed paragraphs sometimes not updating their position properly when hidden because they're empty
- Fixed panels sometimes not drawing children that came into view when their positions changed unexpectedly
- Fixed UiMarkdownParser not parsing formatting in headings and blockquotes
- Fixed Element.OnChildAdded and Element.OnChildRemoved being called for grandchildren when a child is added
- Fixed an exception when trying to force-update the area of an element without a ui system
- Fixed the scroll bar of an empty panel being positioned incorrectly
- Fixed UiControls maintaining old input states when input types are toggled off
- Fixed an occasional deadlock when a game is disposed with a scrolling Panel present
- Fixed UiStyle.LinkColor not being applied to the ui system when changed
Removals
- Marked Element.OnDisposed as obsolete in favor of the more predictable OnRemovedFromUi
### MLEM.Data
Additions
- Added data, from, and copy instructions to DataTextureAtlas
- Added the ability to add additional regions to a RuntimeTexturePacker after packing
- Added GetFlags, GetUniqueFlags and IsDefined to DynamicEnum
- Added DataTextureAtlas.ToDictionary
Improvements
- Allow data texture atlas pivots and offsets to be negative
- Made RuntimeTexturePacker restore texture region name and pivot when packing
- Multi-target net452, making MLEM compatible with MonoGame for consoles
- Added trimming and AOT annotations and made MLEM.Data trimmable
- Store a RuntimeTexturePacker packed texture region's source region
- Use JSON.NET attributes in favor of DataContract and DataMember
- Made JsonTypeSafeWrapper.Of generic to potentially avoid reflective instantiation
Fixes
- Fixed data texture atlases not allowing most characters in their region names
Removals
- Marked DynamicEnum as obsolete due to its reimplementation in [DynamicEnums](https://www.nuget.org/packages/DynamicEnums)
## MLEM.Extended
Additions
- Added Range extension methods GetPercentage and FromPercentage
Improvements
- Multi-target net452, making MLEM compatible with MonoGame for consoles
- Added trimming and AOT annotations and made MLEM.Extended trimmable
- **Made GenericBitmapFont and GenericStashFont support UTF-32 characters like emoji**
## MLEM.Startup
Improvements
- Multi-target net452, making MLEM compatible with MonoGame for consoles
- Added trimming and AOT annotations and made MLEM.Startup trimmable
## 6.0.0
### MLEM
Additions
- Added consuming variants of IsPressed methods to InputHandler and Keybind
@ -95,6 +216,7 @@ Improvements
- Updated to MonoGame 3.8.1
## 5.3.0
### MLEM
Additions
- Added StringBuilder overloads to GenericFont
@ -179,6 +301,7 @@ Removals
- Marked CopyExtensions as obsolete
## 5.2.0
### MLEM
Additions
- Added a strikethrough formatting code
@ -253,6 +376,7 @@ Additions
- Added PreDraw and PreUpdate events and coroutine events
## 5.1.0
### MLEM
Additions
- Added RotateBy to Direction2Helper
@ -303,6 +427,7 @@ Fixes
- Fixed DynamicEnum AddFlag going into an infinite loop
## 5.0.0
### MLEM
Additions
- Added some Collection extensions, namely for dealing with combinations

View file

@ -2,6 +2,8 @@ using Android.Content;
using Android.Content.PM;
using Android.OS;
using Android.Views;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Input;
using MLEM.Extensions;
using MLEM.Misc;
using Uri = Android.Net.Uri;

View file

@ -7,11 +7,12 @@
<ApplicationVersion>1</ApplicationVersion>
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
<ImplicitUsings>true</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.1.263" />
<PackageReference Include="MonoGame.Framework.Android" Version="3.8.1.263" />
<PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.1.303" />
<PackageReference Include="MonoGame.Framework.Android" Version="3.8.1.303" />
<ProjectReference Include="..\Demos\Demos.csproj" />
</ItemGroup>

View file

@ -2,11 +2,12 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<TargetFramework>net6.0</TargetFramework>
<ApplicationIcon>Icon.ico</ApplicationIcon>
<AssemblyName>MLEM Desktop Demos</AssemblyName>
<RootNamespace>Demos.DesktopGL</RootNamespace>
<DefineConstants>$(DefineConstants);FNA</DefineConstants>
<IsPackable>false</IsPackable>
<!-- We still use the MG content builder for ease of compatibility between the MG and FNA demo projects -->
<MonoGamePlatform>DesktopGL</MonoGamePlatform>
</PropertyGroup>
@ -19,7 +20,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.0.1641" />
<PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.1.303" />
<ProjectReference Include="..\FNA\FNA.Core.csproj" />
</ItemGroup>

View file

@ -5,14 +5,15 @@
<TargetFramework>net6.0</TargetFramework>
<ApplicationIcon>Icon.ico</ApplicationIcon>
<AssemblyName>MLEM Desktop Demos</AssemblyName>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
</ItemGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.1.263" />
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.1.263" />
<PackageReference Include="MonoGame.Content.Builder.Task" Version="3.8.1.303" />
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.1.303" />
<ProjectReference Include="..\Demos\Demos.csproj" />
<ProjectReference Include="..\MLEM.Startup\MLEM.Startup.csproj" />

View file

@ -1,20 +1,22 @@
using System;
using MLEM.Misc;
#if !FNA
using Microsoft.Xna.Framework;
#else
using Microsoft.Xna.Framework.Input;
using MLEM.Misc;
#endif
namespace Demos.DesktopGL {
public static class Program {
namespace Demos.DesktopGL;
public static class Program {
public static void Main() {
#if FNA
#if FNA
MlemPlatform.Current = new MlemPlatform.DesktopFna(a => TextInputEXT.TextInput += a);
#else
#else
MlemPlatform.Current = new MlemPlatform.DesktopGl<TextInputEventArgs>((w, c) => w.TextInput += c);
#endif
#endif
using var game = new GameImpl();
game.Run();
}
}
}

View file

@ -4,6 +4,7 @@
<TargetFramework>netstandard2.0</TargetFramework>
<RootNamespace>Demos</RootNamespace>
<DefineConstants>$(DefineConstants);FNA</DefineConstants>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>

View file

@ -1,7 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
@ -11,7 +12,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.1.263">
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.0.1641">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

View file

@ -43,7 +43,7 @@ namespace Demos {
this.GraphicsDevice.Clear(Color.CornflowerBlue);
base.DoDraw(time);
this.SpriteBatch.Begin(SpriteSortMode.Deferred, null, SamplerState.PointClamp, null, null);
this.SpriteBatch.Begin(SpriteSortMode.Deferred, null, SamplerState.PointClamp, DepthStencilState.None, RasterizerState.CullCounterClockwise);
var view = this.GraphicsDevice.Viewport;
// graph the easing function

View file

@ -9,9 +9,6 @@ using MLEM.Textures;
using MLEM.Ui;
using MLEM.Ui.Elements;
using MLEM.Ui.Style;
#if !FNA
using MonoGame.Framework.Utilities;
#endif
namespace Demos {
public class GameImpl : MlemGame {
@ -94,9 +91,8 @@ namespace Demos {
TextScale = 0.1F,
PanelTexture = new NinePatch(new TextureRegion(tex, 0, 8, 24, 24), 8),
ButtonTexture = new NinePatch(new TextureRegion(tex, 24, 8, 16, 16), 4),
ScrollBarBackground = new NinePatch(new TextureRegion(tex, 12, 0, 4, 8), 1, 1, 2, 2),
ScrollBarScrollerTexture = new NinePatch(new TextureRegion(tex, 8, 0, 4, 8), 1, 1, 2, 2),
LinkColor = Color.CornflowerBlue
ScrollBarBackground = new NinePatch(new TextureRegion(tex, 12, 0, 4, 8), 0.25F, paddingPercent: true),
ScrollBarScrollerTexture = new NinePatch(new TextureRegion(tex, 8, 0, 4, 8), 0.25F, paddingPercent: true)
};
}

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Extensions;
@ -51,7 +52,7 @@ namespace Demos {
// Now find a path from the top left to the bottom right corner and store it in a variable
// If no path can be found after the maximum amount of tries (10000 by default), the pathfinder will abort and return no path (null)
var foundPath = await this.pathfinder.FindPathAsync(Point.Zero, new Point(49, 49));
var foundPath = await Task.Run(() => this.pathfinder.FindPath(Point.Zero, new Point(49, 49)));
this.path = foundPath != null ? foundPath.ToList() : null;
// print out some info

View file

@ -1,9 +1,12 @@
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using MLEM.Extensions;
using MLEM.Font;
using MLEM.Formatting;
using MLEM.Formatting.Codes;
using MLEM.Misc;
using MLEM.Startup;
using MLEM.Textures;
@ -15,7 +18,7 @@ namespace Demos {
"You can write in <b>bold</i>, <i>italics</i>, <u>with an underline</u>, <st>strikethrough</st>, with a <s #000000 4>drop shadow</s> whose <s #ff0000 4>color</s> and <s #000000 10>offset</s> you can modify in each application of the code, or with various types of <b>combined <c Pink>formatting</c> codes</b>.\n\n" +
"You can apply <c CornflowerBlue>custom</c> <c Yellow>colors</c> to text, including all default <c Orange>MonoGame colors</c> and <c #aabb00>inline custom colors</c>.\n\n" +
"You can also use animations like <a wobbly>a wobbly one</a>, as well as create custom ones using the <a wobbly>Code class</a>.\n\n" +
"You can also display <i grass> icons in your text!\n\n" +
"You can also display <i grass> icons in your text, and use super<sup>script</sup> or sub<sub>script</sub> formatting!\n\n" +
"Additionally, the text formatter has various methods for interacting with the text, like custom behaviors when hovering over certain parts, and more.";
private const float Scale = 0.5F;
private const float Width = 0.9F;
@ -23,6 +26,7 @@ namespace Demos {
private TextFormatter formatter;
private TokenizedString tokenizedText;
private GenericFont font;
private bool drawBounds;
public TextFormattingDemo(MlemGame game) : base(game) {}
@ -50,12 +54,24 @@ namespace Demos {
public override void DoDraw(GameTime time) {
this.GraphicsDevice.Clear(Color.DarkSlateGray);
this.SpriteBatch.Begin(SpriteSortMode.Deferred, null, SamplerState.PointClamp, null, null);
this.SpriteBatch.Begin(SpriteSortMode.Deferred, null, SamplerState.PointClamp, DepthStencilState.None, RasterizerState.CullCounterClockwise);
// we draw the tokenized text in the center of the screen
// since the text is already center-aligned, we only need to align it on the y axis here
var size = this.tokenizedText.Measure(this.font) * TextFormattingDemo.Scale;
var size = this.tokenizedText.GetArea(Vector2.Zero, TextFormattingDemo.Scale).Size;
var pos = new Vector2(this.GraphicsDevice.Viewport.Width / 2, (this.GraphicsDevice.Viewport.Height - size.Y) / 2);
// draw bounds, which can be toggled with B in this demo
if (this.drawBounds) {
var blank = this.SpriteBatch.GetBlankTexture();
this.SpriteBatch.Draw(blank, new RectangleF(pos - new Vector2(size.X / 2, 0), size), Color.Red * 0.25F);
foreach (var token in this.tokenizedText.Tokens) {
foreach (var area in token.GetArea(pos, TextFormattingDemo.Scale))
this.SpriteBatch.Draw(blank, area, Color.Black * 0.25F);
}
}
// draw the text itself
this.tokenizedText.Draw(time, this.SpriteBatch, pos, this.font, Color.White, TextFormattingDemo.Scale, 0);
this.SpriteBatch.End();
@ -64,6 +80,8 @@ namespace Demos {
public override void Update(GameTime time) {
// update our tokenized string to animate the animation codes
this.tokenizedText.Update(time);
if (this.InputHandler.IsPressed(Keys.B))
this.drawBounds = !this.drawBounds;
}
public override void Clear() {

View file

@ -51,8 +51,7 @@ namespace Demos {
CheckboxCheckmark = new TextureRegion(this.testTexture, 24, 0, 8, 8),
RadioTexture = new NinePatch(new TextureRegion(this.testTexture, 16, 0, 8, 8), 3),
RadioCheckmark = new TextureRegion(this.testTexture, 32, 0, 8, 8),
AdditionalFonts = {{"Monospaced", new GenericSpriteFont(Demo.LoadContent<SpriteFont>("Fonts/MonospacedFont"))}},
LinkColor = Color.CornflowerBlue
AdditionalFonts = {{"Monospaced", new GenericSpriteFont(Demo.LoadContent<SpriteFont>("Fonts/MonospacedFont"))}}
};
var untexturedStyle = new UntexturedStyle(this.SpriteBatch) {
TextScale = style.TextScale,
@ -69,7 +68,6 @@ namespace Demos {
// create the root panel that all the other components sit on and add it to the ui system
this.root = new Panel(Anchor.Center, new Vector2(80, 100), Vector2.Zero, false, true);
this.root.ScrollBar.SmoothScrolling = true;
// add the root to the demos' ui
this.UiRoot.AddChild(this.root);
@ -94,11 +92,18 @@ namespace Demos {
// a paragraph with formatting codes. To see them all or to add more, check the TextFormatting class
this.root.AddChild(new Paragraph(Anchor.AutoLeft, 1, "Paragraphs can also contain <c Blue>formatting codes</c>, including colors and <i>text styles</i>. For more info, check out the <b>text formatting demo</b>!"));
this.root.AddChild(new VerticalSpace(3));
this.root.AddChild(new Paragraph(Anchor.AutoCenter, 1, "Text input:", true));
this.root.AddChild(new TextField(Anchor.AutoLeft, new Vector2(1, 10)) {
PositionOffset = new Vector2(0, 1),
PlaceholderText = "Click here to input text"
});
this.root.AddChild(new VerticalSpace(3));
this.root.AddChild(new Paragraph(Anchor.AutoCenter, 1, "Multiline text input:", true));
this.root.AddChild(new TextField(Anchor.AutoLeft, new Vector2(1, 50), multiline: true) {
PositionOffset = new Vector2(0, 1),
PlaceholderText = "Click here to input text"
PlaceholderText = "Click here to input a lot"
});
this.root.AddChild(new VerticalSpace(3));
@ -205,12 +210,24 @@ namespace Demos {
dropdown.AddElement(new Button(Anchor.AutoLeft, new Vector2(1, 10), "Buttons"));
this.root.AddChild(new VerticalSpace(3));
this.root.AddChild(new Button(Anchor.AutoLeft, new Vector2(1, 10), "Disabled button", "This button can't be clicked or moved to using automatic navigation") {IsDisabled = true}).PositionOffset = new Vector2(0, 1);
this.root.AddChild(new Checkbox(Anchor.AutoLeft, new Vector2(1, 10), "Disabled checkbox") {IsDisabled = true}).PositionOffset = new Vector2(0, 1);
this.root.AddChild(new Button(Anchor.AutoLeft, new Vector2(1, 10), "Disabled button", "This button can't be clicked or moved to using automatic navigation") {
IsDisabled = true,
PositionOffset = new Vector2(0, 1)
});
this.root.AddChild(new Checkbox(Anchor.AutoLeft, new Vector2(1, 10), "Disabled checkbox") {
IsDisabled = true,
PositionOffset = new Vector2(0, 1)
});
this.root.AddChild(new Button(Anchor.AutoLeft, new Vector2(1, 10), "Disabled tooltip button", "This button can't be clicked, but can be moved to using automatic navigation, and will display its tooltip even when done so.") {
CanSelectDisabled = true,
IsDisabled = true,
Tooltip = {DisplayInAutoNavMode = true},
PositionOffset = new Vector2(0, 1),
Tooltip = {
DisplayInAutoNavMode = true
}
});
this.root.AddChild(new Button(Anchor.AutoLeft, new Vector2(1, 10), "Button with <b>far</b> too much text which will automatically be cut off, hi!") {
TruncateTextIfLong = true,
PositionOffset = new Vector2(0, 1)
});

View file

@ -50,3 +50,8 @@ if (this.InputHandler.GetGesture(GestureType.Tap, out var sample)) {
// The gesture did not happen this frame
}
```
### External gesture handling
If your game already handles gestures through some other means, you might notice that one of the gesture handling methods stops working correctly. This is due to the fact that MonoGame's gesture querying system only supports each gesture to be queried once before it is removed from the queue.
If you want to continue using your own gesture handling, but still allow the `InputHandler` to use gestures (for [MLEM.Ui](ui.md), for example), you can set `GesturesExternal` to true in your `InputHandler`. Then, you can use `AddExternalGesture` to make the input handler aware of a gesture for the duration of the update frame that you added it on.

View file

@ -2,7 +2,8 @@
"metadata": [{
"src": [{
"src": "../",
"files": ["**/MLEM**.csproj"]
"files": ["**/MLEM**.csproj"],
"exclude": ["**.FNA.**"]
}],
"dest": "api"
}],
@ -44,8 +45,7 @@
"fileMetadataFiles": [],
"template": [
"default",
"templates/darkfx",
"templates/custom"
"templates/darkfx"
],
"postProcessors": [],
"markdownEngineName": "markdig",

View file

@ -2,6 +2,8 @@
**MLEM Library for Extending MonoGame and FNA** is a set of multipurpose libraries for the game frameworks [MonoGame](https://www.monogame.net/) and [FNA](https://fna-xna.github.io/) that provides abstractions, quality of life improvements and additional features like an extensive ui system and easy input handling.
MLEM is platform-agnostic and multi-targets .NET Standard 2.0, .NET 6.0 and .NET Framework 4.5.2, which makes it compatible with MonoGame and FNA on Desktop, mobile devices and consoles.
# What next?
- Get it on [NuGet](https://www.nuget.org/packages?q=mlem)
- Get prerelease builds on [BaGet](https://nuget.ellpeck.de/?q=mlem)
@ -44,3 +46,4 @@ There are several other libraries and tools that work well in combination with M
- [MonoGame.Extended](https://github.com/craftworkgames/MonoGame.Extended), a package that also provides several additional features for MonoGame
- [Coroutine](https://github.com/Ellpeck/Coroutine), a package that implements Unity-style coroutines for any project
- [Illumilib](https://github.com/Ellpeck/Illumilib), a simple keyboard and mouse lighting library with support for Razer, Logitech and Corsair devices
- [DynamicEnums](https://github.com/Ellpeck/DynamicEnums), which provides enum-like single-instance values with additional capabilities, including dynamic addition of new arbitrary values and flags

View file

@ -1,32 +0,0 @@
{{!Copyright (c) Microsoft. All rights reserved. Licensed under the MIT license. See LICENSE file in the project root for full license information.}}
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>{{#title}}{{title}}{{/title}}{{^title}}{{>partials/title}}{{/title}} {{#_appTitle}}| {{_appTitle}} {{/_appTitle}}</title>
<meta name="viewport" content="width=device-width">
<meta name="title" content="{{#title}}{{title}}{{/title}}{{^title}}{{>partials/title}}{{/title}} {{#_appTitle}}| {{_appTitle}} {{/_appTitle}}">
<meta name="generator" content="docfx {{_docfxVersion}}">
{{#_description}}<meta name="description" content="{{_description}}">{{/_description}}
<link rel="shortcut icon" href="{{_rel}}{{{_appFaviconPath}}}{{^_appFaviconPath}}favicon.ico{{/_appFaviconPath}}">
<link rel="stylesheet" href="{{_rel}}styles/docfx.vendor.css">
<link rel="stylesheet" href="{{_rel}}styles/docfx.css">
<link rel="stylesheet" href="{{_rel}}styles/main.css">
<meta property="docfx:navrel" content="{{_navRel}}">
<meta property="docfx:tocrel" content="{{_tocRel}}">
{{#_noindex}}<meta name="searchOption" content="noindex">{{/_noindex}}
{{#_enableSearch}}<meta property="docfx:rel" content="{{_rel}}">{{/_enableSearch}}
{{#_enableNewTab}}<meta property="docfx:newtab" content="true">{{/_enableNewTab}}
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-85JBDH1P3Z"></script>
<script>
window.dataLayer = window.dataLayer || [];
let gtag = function () {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-85JBDH1P3Z');
</script>
</head>

2
FNA

@ -1 +1 @@
Subproject commit 102990f514f1e5bfac07d33f7c33e2e712946da4
Subproject commit 9029e149358197612509e2ee4893870a3b5d590e

15
FNA.Settings.props Normal file
View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<PlatformTarget>AnyCPU</PlatformTarget>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<!-- include reference assemblies so we can build for old framework versions without having to install them -->
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.2" />
</ItemGroup>
<!-- dummy pack target to allow for this (non-SDK-style) project to be included in dotnet pack -->
<Target Name="Pack" />
</Project>

@ -1 +1 @@
Subproject commit f0774130cad6cec0b790a58bc7c811a186443fb3
Subproject commit c50bf544bcfb217b518727a0a38eb71fc5725092

View file

@ -5,33 +5,52 @@ using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
#if NET6_0_OR_GREATER
using System.Diagnostics.CodeAnalysis;
#endif
namespace MLEM.Data.Content {
/// <summary>
/// Represents a version of <see cref="ContentManager"/> that doesn't load content binary <c>xnb</c> files, but rather as their regular formats.
/// </summary>
public class RawContentManager : ContentManager, IGameComponent {
private static List<RawContentReader> readers;
/// <summary>
/// The graphics device that this content manager uses
/// </summary>
public readonly GraphicsDevice GraphicsDevice;
private readonly List<IDisposable> disposableAssets = new List<IDisposable>();
#if FNA
private readonly List<RawContentReader> readers;
#if FNA
private Dictionary<string, object> LoadedAssets { get; } = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
#endif
#endif
/// <summary>
/// Creates a new content manager with an optionally specified root directory.
/// Each <see cref="RawContentReader"/> required for asset loading is gathered and instantiated automatically from the loaded assemblies.
/// </summary>
/// <param name="serviceProvider">The service provider of your game</param>
/// <param name="rootDirectory">The root directory. Defaults to "Content"</param>
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Automatically gathered RawContentReader types might be removed, use other constructor to add readers manually")]
#endif
public RawContentManager(IServiceProvider serviceProvider, string rootDirectory = "Content") :
this(serviceProvider, RawContentManager.CollectContentReaders(), rootDirectory) {}
/// <summary>
/// Creates a new content manager with an optionally specified root directory.
/// Each <see cref="RawContentReader"/> required for asset loading has to be passed using <paramref name="readers"/>.
/// </summary>
/// <param name="serviceProvider">The service provider of your game</param>
/// <param name="readers">The raw content readers to use, which can be modified externally afterwards to add additional readers if desired.</param>
/// <param name="rootDirectory">The root directory. Defaults to "Content"</param>
public RawContentManager(IServiceProvider serviceProvider, List<RawContentReader> readers, string rootDirectory) :
base(serviceProvider, rootDirectory) {
if (serviceProvider.GetService(typeof(IGraphicsDeviceService)) is IGraphicsDeviceService s)
this.GraphicsDevice = s.GraphicsDevice;
this.readers = readers;
}
/// <summary>
@ -54,18 +73,16 @@ namespace MLEM.Data.Content {
/// <param name="currentAsset">The current asset instance.</param>
/// <typeparam name="T">The asset's type.</typeparam>
protected
#if !FNA
#if !FNA
override
#endif
#endif
void ReloadAsset<T>(string originalAssetName, T currentAsset) {
this.Read(originalAssetName, currentAsset);
}
private T Read<T>(string assetName, T existing) {
var triedFiles = new List<string>();
if (RawContentManager.readers == null)
RawContentManager.readers = RawContentManager.CollectContentReaders();
foreach (var reader in RawContentManager.readers) {
foreach (var reader in this.readers) {
if (!reader.CanRead(typeof(T)))
continue;
foreach (var ext in reader.GetFileExtensions()) {
@ -104,6 +121,9 @@ namespace MLEM.Data.Content {
/// </summary>
public void Initialize() {}
#if NET6_0_OR_GREATER
[RequiresUnreferencedCode("Automatically gathered RawContentReader types might be removed, use other constructor to add readers manually")]
#endif
private static List<RawContentReader> CollectContentReaders() {
var ret = new List<RawContentReader>();
var assemblyExceptions = new List<Exception>();

View file

@ -9,13 +9,13 @@ namespace MLEM.Data.Content {
/// <inheritdoc />
protected override Texture2D Read(RawContentManager manager, string assetPath, Stream stream, Texture2D existing) {
#if !FNA
#if !FNA
if (existing != null) {
existing.Reload(stream);
return existing;
} else
#endif
{
}
#endif
// premultiply the texture's color to be in line with the pipeline's texture reader
using (var texture = Texture2D.FromStream(manager.GraphicsDevice, stream)) {
var ret = new Texture2D(manager.GraphicsDevice, texture.Width, texture.Height);
@ -30,7 +30,6 @@ namespace MLEM.Data.Content {
return ret;
}
}
}
/// <inheritdoc />
public override string[] GetFileExtensions() {

View file

@ -12,6 +12,10 @@ namespace MLEM.Data.Content {
}
/// <inheritdoc />
#if NET6_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2026",
Justification = "RawContentManager does not support XmlReader in a trimmed or AOT context, so this method is not expected to be called.")]
#endif
public override object Read(RawContentManager manager, string assetPath, Stream stream, Type t, object existing) {
return new XmlSerializer(t).Deserialize(stream);
}

View file

@ -3,11 +3,18 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
#if NET6_0_OR_GREATER
using System.Diagnostics.CodeAnalysis;
#endif
namespace MLEM.Data {
/// <summary>
/// A set of extensions for dealing with copying objects.
/// </summary>
[Obsolete("CopyExtensions has major flaws and insufficient speed compared to other libraries specifically designed for copying objects.")]
#if NET6_0_OR_GREATER
[UnconditionalSuppressMessage("Aot", "IL3050"), UnconditionalSuppressMessage("Aot", "IL2070"), UnconditionalSuppressMessage("Aot", "IL2090")]
#endif
public static class CopyExtensions {
private const BindingFlags DefaultFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;

View file

@ -7,6 +7,7 @@ using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Content;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Extensions;
using MLEM.Misc;
using MLEM.Textures;
namespace MLEM.Data {
@ -17,15 +18,20 @@ namespace MLEM.Data {
/// </para>
/// <para>
/// Data texture atlases are designed to be easy to write by hand. Because of this, their structure is very simple.
/// Each texture region defined in the atlas consists of its name, followed by a set of possible keywords and their arguments, separated by spaces.
/// The <c>loc</c> keyword defines the <see cref="TextureRegion.Area"/> of the texture region as a rectangle whose origin is its top-left corner. It requires four arguments: x, y, width and height of the rectangle.
/// The (optional) <c>piv</c> keyword defines the <see cref="TextureRegion.PivotPixels"/> of the texture region. It requires two arguments: x and y. If it is not supplied, the pivot defaults to the top-left corner of the texture region.
/// The (optional) <c>off</c> keyword defines an offset that is added onto the location and pivot of this texture region. This is useful when copying and pasting a previously defined texture region to create a second region that has a constant offset. It requires two arguments: The x and y offset.
/// Each texture region defined in the atlas consists of its names (where multiple names can be separated by whitespace), followed by a set of possible instructions and their arguments, also separated by whitespace.
/// <list type="bullet">
/// <item><description>The <c>loc</c> (or <c>location</c>) instruction defines the <see cref="TextureRegion.Area"/> of the texture region as a rectangle whose origin is its top-left corner. It requires four arguments: x, y, width and height of the rectangle.</description></item>
/// <item><description>The (optional) <c>piv</c> (or <c>pivot</c>) instruction defines the <see cref="TextureRegion.PivotPixels"/> of the texture region. It requires two arguments: x and y. If it is not supplied, the pivot defaults to the top-left corner of the texture region.</description></item>
/// <item><description>The (optional) <c>off</c> (of <c>offset</c>) instruction defines an offset that is added onto the location and pivot of this texture region. This is useful when duplicating a previously defined texture region to create a second region that has a constant offset. It requires two arguments: The x and y offset.</description></item>
/// <item><description>The (optional and repeatable) <c>cpy</c> (or <c>copy</c>) instruction defines an additional texture region that should also be generated from the same data, but with a given offset that will be applied to the location and pivot. It requires three arguments: the copy region's name and the x and y offsets.</description></item>
/// <item><description>The (optional and repeatable) <c>dat</c> (or <c>data</c>) instruction defines a custom data point that can be added to the resulting <see cref="TextureRegion"/>'s <see cref="GenericDataHolder"/> data. It requires two arguments: the data point's name and the data point's value, the latter of which is also stored as a string value.</description></item>
/// <item><description>The (optional) <c>frm</c> (or <c>from</c>) instruction defines a texture region (defined before the current region) whose data should be copied. All data from the region will be copied, but adding additional instructions afterwards modifies the data. It requires one argument: the name of the region whose data to copy. If this instruction is used, the <c>loc</c> instruction is not required.</description></item>
/// </list>
/// </para>
/// <example>
/// The following entry defines a texture region with the name <c>LongTableRight</c>, whose <see cref="TextureRegion.Area"/> will be a rectangle with X=32, Y=30, Width=64, Height=48, and whose <see cref="TextureRegion.PivotPixels"/> will be a vector with X=80, Y=46.
/// The following entry defines a texture region with the names <c>LongTableRight</c> and <c>LongTableUp</c>, whose <see cref="TextureRegion.Area"/> will be a rectangle with X=32, Y=30, Width=64, Height=48, and whose <see cref="TextureRegion.PivotPixels"/> will be a vector with X=80, Y=46.
/// <code>
/// LongTableRight
/// LongTableRight LongTableUp
/// loc 32 30 64 48
/// piv 80 46
/// </code>
@ -66,8 +72,18 @@ namespace MLEM.Data {
this.Texture = texture;
}
/// <summary>
/// Converts this data texture atlas to a <see cref="Dictionary{TKey,TValue}"/> and returns the result.
/// The resulting dictionary will contain all named regions that this data texture atlas contains.
/// </summary>
/// <returns>The dictionary representation of this data texture atlas.</returns>
public Dictionary<string, TextureRegion> ToDictionary() {
return new Dictionary<string, TextureRegion>(this.regions);
}
/// <summary>
/// Loads a <see cref="DataTextureAtlas"/> from the given loaded texture and texture data file.
/// For more information on data texture atlases, see the <see cref="DataTextureAtlas"/> type documentation.
/// </summary>
/// <param name="texture">The texture to use for this data texture atlas</param>
/// <param name="content">The content manager to use for loading</param>
@ -77,45 +93,124 @@ namespace MLEM.Data {
public static DataTextureAtlas LoadAtlasData(TextureRegion texture, ContentManager content, string infoPath, bool pivotRelative = false) {
var info = Path.Combine(content.RootDirectory, infoPath);
string text;
try {
if (Path.IsPathRooted(info)) {
text = File.ReadAllText(info);
} else {
using (var reader = new StreamReader(TitleContainer.OpenStream(info)))
text = reader.ReadToEnd();
}
} catch (Exception e) {
throw new ContentLoadException($"Couldn't load data texture atlas data from {info}", e);
}
var atlas = new DataTextureAtlas(texture);
var words = Regex.Split(text, @"\s+");
// parse each texture region: "<names> loc <u> <v> <w> <h> [piv <px> <py>] [off <ox> <oy>]"
foreach (Match match in Regex.Matches(text, @"(.+)\W+loc\W+([0-9]+)\W+([0-9]+)\W+([0-9]+)\W+([0-9]+)\W*(?:piv\W+([0-9.]+)\W+([0-9.]+))?\W*(?:off\W+([0-9.]+)\W+([0-9.]+))?")) {
// offset
var off = !match.Groups[8].Success ? Vector2.Zero : new Vector2(
float.Parse(match.Groups[8].Value, CultureInfo.InvariantCulture),
float.Parse(match.Groups[9].Value, CultureInfo.InvariantCulture));
var namesOffsets = new List<(string, Vector2)>();
var customData = new Dictionary<string, string>();
var location = Rectangle.Empty;
var pivot = Vector2.Zero;
var offset = Vector2.Zero;
for (var i = 0; i < words.Length; i++) {
var word = words[i];
try {
switch (word) {
case "loc":
case "location":
location = new Rectangle(
int.Parse(words[i + 1], CultureInfo.InvariantCulture), int.Parse(words[i + 2], CultureInfo.InvariantCulture),
int.Parse(words[i + 3], CultureInfo.InvariantCulture), int.Parse(words[i + 4], CultureInfo.InvariantCulture));
i += 4;
break;
case "piv":
case "pivot":
pivot = new Vector2(
float.Parse(words[i + 1], CultureInfo.InvariantCulture),
float.Parse(words[i + 2], CultureInfo.InvariantCulture));
i += 2;
break;
case "off":
case "offset":
offset = new Vector2(
float.Parse(words[i + 1], CultureInfo.InvariantCulture),
float.Parse(words[i + 2], CultureInfo.InvariantCulture));
i += 2;
break;
case "cpy":
case "copy":
var copyOffset = new Vector2(
float.Parse(words[i + 2], CultureInfo.InvariantCulture),
float.Parse(words[i + 3], CultureInfo.InvariantCulture));
namesOffsets.Add((words[i + 1], copyOffset));
i += 3;
break;
case "dat":
case "data":
customData.Add(words[i + 1], words[i + 2]);
i += 2;
break;
case "frm":
case "from":
var fromRegion = atlas[words[i + 1]];
customData.Clear();
foreach (var key in fromRegion.GetDataKeys())
customData.Add(key, fromRegion.GetData<string>(key));
// our main texture might be a sub-region already, so we have to take that into account
location = fromRegion.Area.OffsetCopy(new Point(-texture.U, -texture.V));
pivot = fromRegion.PivotPixels;
if (pivot != Vector2.Zero && !pivotRelative)
pivot += location.Location.ToVector2();
offset = Vector2.Zero;
i += 1;
break;
default:
// if we have data for the previous regions, they're valid so we add them
AddCurrentRegions();
// we're starting a new region (or adding another name for a new region), so clear old data
namesOffsets.Add((word.Trim(), Vector2.Zero));
customData.Clear();
location = Rectangle.Empty;
pivot = Vector2.Zero;
offset = Vector2.Zero;
break;
}
} catch (Exception e) {
throw new ContentLoadException($"Couldn't parse data texture atlas instruction {word} for region(s) {string.Join(", ", namesOffsets)}", e);
}
}
// add the last region that was started on
AddCurrentRegions();
return atlas;
void AddCurrentRegions() {
// the location is the only mandatory information, which is why we check it here
if (location == Rectangle.Empty || namesOffsets.Count <= 0)
return;
foreach (var (name, addedOff) in namesOffsets) {
var loc = location;
var piv = pivot;
var off = offset + addedOff;
// location
var loc = new Rectangle(
int.Parse(match.Groups[2].Value), int.Parse(match.Groups[3].Value),
int.Parse(match.Groups[4].Value), int.Parse(match.Groups[5].Value));
loc.Offset(off.ToPoint());
if (piv != Vector2.Zero) {
piv += off;
if (!pivotRelative)
piv -= loc.Location.ToVector2();
}
// pivot
var piv = !match.Groups[6].Success ? Vector2.Zero : off + new Vector2(
float.Parse(match.Groups[6].Value, CultureInfo.InvariantCulture) - (pivotRelative ? 0 : loc.X),
float.Parse(match.Groups[7].Value, CultureInfo.InvariantCulture) - (pivotRelative ? 0 : loc.Y));
foreach (var name in Regex.Split(match.Groups[1].Value, @"\W")) {
var trimmed = name.Trim();
if (trimmed.Length <= 0)
continue;
var region = new TextureRegion(texture, loc) {
PivotPixels = piv,
Name = trimmed
Name = name
};
atlas.regions.Add(trimmed, region);
foreach (var kv in customData)
region.SetData(kv.Key, kv.Value);
atlas.regions.Add(name, region);
}
// we only clear names offsets if the location was valid, otherwise we ignore multiple names for a region
namesOffsets.Clear();
}
return atlas;
}
}
@ -127,6 +222,7 @@ namespace MLEM.Data {
/// <summary>
/// Loads a <see cref="DataTextureAtlas"/> from the given texture and texture data file.
/// For more information on data texture atlases, see the <see cref="DataTextureAtlas"/> type documentation.
/// </summary>
/// <param name="content">The content manager to use for loading</param>
/// <param name="texturePath">The path to the texture file</param>

View file

@ -13,9 +13,6 @@ namespace MLEM.Data {
/// A dynamic enum uses <see cref="BigInteger"/> as its underlying type, allowing for an arbitrary number of enum values to be created, even when a <see cref="FlagsAttribute"/>-like structure is used that would only allow for up to 64 values in a regular enum.
/// All enum operations including <see cref="And{T}"/>, <see cref="Or{T}"/>, <see cref="Xor{T}"/> and <see cref="Neg{T}"/> are supported and can be implemented in derived classes using operator overloads.
/// To create a custom dynamic enum, simply create a class that extends <see cref="DynamicEnum"/>. New values can then be added using <see cref="Add{T}"/>, <see cref="AddValue{T}"/> or <see cref="AddFlag{T}"/>.
///
/// This class, and its entire concept, are extremely terrible. If you intend on using this, there's probably at least one better solution available.
/// Though if, for some weird reason, you need a way to have more than 64 distinct flags, this is a pretty good solution.
/// </summary>
/// <remarks>
/// To include enum-like operator overloads in a dynamic enum named MyEnum, the following code can be used:
@ -28,7 +25,10 @@ namespace MLEM.Data {
/// public static MyEnum operator ~(MyEnum value) => Neg(value);
/// </code>
/// </remarks>
[JsonConverter(typeof(DynamicEnumConverter))]
[Obsolete("DynamicEnum has been moved into the DynamicEnums library: https://www.nuget.org/packages/DynamicEnums"), JsonConverter(typeof(DynamicEnumConverter))]
#if NET6_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Aot", "IL2067")]
#endif
public abstract class DynamicEnum {
private static readonly Dictionary<Type, Storage> Storages = new Dictionary<Type, Storage>();
@ -134,7 +134,7 @@ namespace MLEM.Data {
/// <returns>The newly created enum value</returns>
public static T AddValue<T>(string name) where T : DynamicEnum {
BigInteger value = 0;
while (DynamicEnum.GetStorage(typeof(T)).Values.ContainsKey(value))
while (DynamicEnum.IsDefined(typeof(T), value))
value++;
return DynamicEnum.Add<T>(name, value);
}
@ -149,7 +149,7 @@ namespace MLEM.Data {
/// <returns>The newly created enum value</returns>
public static T AddFlag<T>(string name) where T : DynamicEnum {
BigInteger value = 1;
while (DynamicEnum.GetStorage(typeof(T)).Values.ContainsKey(value))
while (DynamicEnum.IsDefined(typeof(T), value))
value <<= 1;
return DynamicEnum.Add<T>(name, value);
}
@ -174,6 +174,42 @@ namespace MLEM.Data {
return DynamicEnum.GetStorage(type).Values.Values;
}
/// <summary>
/// Returns all of the defined values from the given dynamic enum type <typeparamref name="T"/> which are contained in <paramref name="combinedFlag"/>.
/// Note that, if combined flags are defined in <typeparamref name="T"/>, and <paramref name="combinedFlag"/> contains them, they will also be returned.
/// </summary>
/// <param name="combinedFlag">The combined flags whose individual flags to return.</param>
/// <param name="includeZero">Whether the enum value 0 should also be returned, if <typeparamref name="T"/> contains one.</param>
/// <typeparam name="T">The type of enum.</typeparam>
/// <returns>All of the flags that make up <paramref name="combinedFlag"/>.</returns>
public static IEnumerable<T> GetFlags<T>(T combinedFlag, bool includeZero = true) where T : DynamicEnum {
foreach (var flag in DynamicEnum.GetValues<T>()) {
if (combinedFlag.HasFlag(flag) && (includeZero || DynamicEnum.GetValue(flag) != BigInteger.Zero))
yield return flag;
}
}
/// <summary>
/// Returns all of the defined unique flags from the given dynamic enum type <typeparamref name="T"/> which are contained in <paramref name="combinedFlag"/>.
/// Any combined flags (flags that aren't powers of two) which are defined in <typeparamref name="T"/> will not be returned.
/// </summary>
/// <param name="combinedFlag">The combined flags whose individual flags to return.</param>
/// <typeparam name="T">The type of enum.</typeparam>
/// <returns>All of the unique flags that make up <paramref name="combinedFlag"/>.</returns>
public static IEnumerable<T> GetUniqueFlags<T>(T combinedFlag) where T : DynamicEnum {
// we can't use the same method here as EnumHelper.GetUniqueFlags since DynamicEnum doesn't guarantee sorted values
var max = DynamicEnum.GetValues<T>().Max(DynamicEnum.GetValue);
var uniqueFlag = BigInteger.One;
while (uniqueFlag <= max) {
if (DynamicEnum.IsDefined(typeof(T), uniqueFlag)) {
var uniqueFlagValue = DynamicEnum.GetEnumValue<T>(uniqueFlag);
if (combinedFlag.HasFlag(uniqueFlagValue))
yield return uniqueFlagValue;
}
uniqueFlag <<= 1;
}
}
/// <summary>
/// Returns the bitwise OR (|) combination of the two dynamic enum values
/// </summary>
@ -292,7 +328,8 @@ namespace MLEM.Data {
/// <summary>
/// Parses the given <see cref="string"/> into a dynamic enum value and returns the result.
/// This method supports defined enum values as well as values combined using the pipe (|) character and any number of spaces.
/// If no enum value can be parsed, null is returned. /// </summary>
/// If no enum value can be parsed, null is returned.
/// </summary>
/// <param name="type">The type of the dynamic enum value to parse</param>
/// <param name="strg">The string to parse into a dynamic enum value</param>
/// <returns>The parsed enum value, or null if parsing fails</returns>
@ -315,6 +352,27 @@ namespace MLEM.Data {
return cached;
}
/// <summary>
/// Returns whether the given <paramref name="value"/> is defined in the given dynamic enum <paramref name="type"/>.
/// A value counts as explicitly defined if it has been added using <see cref="Add{T}"/>, <see cref="AddValue{T}"/> or <see cref="AddFlag{T}"/>.
/// </summary>
/// <param name="type">The dynamic enum type to query.</param>
/// <param name="value">The value to query.</param>
/// <returns>Whether the <paramref name="value"/> is defined.</returns>
public static bool IsDefined(Type type, BigInteger value) {
return DynamicEnum.GetStorage(type).Values.ContainsKey(value);
}
/// <summary>
/// Returns whether the given <paramref name="value"/> is defined in its dynamic enum type.
/// A value counts as explicitly defined if it has been added using <see cref="Add{T}"/>, <see cref="AddValue{T}"/> or <see cref="AddFlag{T}"/>.
/// </summary>
/// <param name="value">The value to query.</param>
/// <returns>Whether the <paramref name="value"/> is defined.</returns>
public static bool IsDefined(DynamicEnum value) {
return value != null && DynamicEnum.IsDefined(value.GetType(), DynamicEnum.GetValue(value));
}
private static Storage GetStorage(Type type) {
if (!DynamicEnum.Storages.TryGetValue(type, out var storage)) {
storage = new Storage();

View file

@ -5,6 +5,7 @@ namespace MLEM.Data.Json {
/// <summary>
/// Converts a <see cref="DynamicEnum"/> to and from JSON
/// </summary>
[Obsolete("DynamicEnum has been moved into the DynamicEnums library: https://www.nuget.org/packages/DynamicEnums"), JsonConverter(typeof(DynamicEnumConverter))]
public class DynamicEnumConverter : JsonConverter<DynamicEnum> {
/// <summary>Writes the JSON representation of the object.</summary>

View file

@ -11,9 +11,16 @@ namespace MLEM.Data.Json {
/// <summary>
/// An array of all of the <see cref="JsonConverter"/>s that are part of MLEM.Data
/// </summary>
public static readonly JsonConverter[] Converters = typeof(JsonConverters).Assembly.GetExportedTypes()
.Where(t => t.IsSubclassOf(typeof(JsonConverter)) && !t.IsGenericType)
.Select(Activator.CreateInstance).Cast<JsonConverter>().ToArray();
public static readonly JsonConverter[] Converters = {
new Direction2Converter(),
#pragma warning disable CS0618
new DynamicEnumConverter(),
#pragma warning restore CS0618
new PointConverter(),
new RectangleConverter(),
new RectangleFConverter(),
new Vector2Converter()
};
/// <summary>
/// Adds all of the <see cref="JsonConverter"/> objects that are part of MLEM.Data to the given <see cref="JsonSerializer"/>

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
using MLEM.Misc;
using Newtonsoft.Json;
@ -8,16 +7,27 @@ namespace MLEM.Data.Json {
/// <summary>
/// An <see cref="IGenericDataHolder"/> represents an object that can hold generic key-value based data.
/// This class uses <see cref="JsonTypeSafeWrapper"/> for each object stored to ensure that objects with a custom <see cref="JsonConverter"/> get deserialized as an instance of their original type if <see cref="JsonSerializer.TypeNameHandling"/> is not set to <see cref="TypeNameHandling.None"/>.
/// Note that, using <see cref="SetData"/>, adding <see cref="JsonTypeSafeWrapper{T}"/> instances directly is also possible.
/// </summary>
[DataContract]
#if NET7_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The native code for instantiation of JsonTypeSafeWrapper instances might not be available at runtime.")]
#endif
public class JsonTypeSafeGenericDataHolder : IGenericDataHolder {
[DataMember(EmitDefaultValue = false)]
private static readonly string[] EmptyStrings = new string[0];
[JsonProperty]
private Dictionary<string, JsonTypeSafeWrapper> data;
/// <inheritdoc />
[Obsolete("This method will be removed in a future update in favor of the generic SetData<T>.")]
public void SetData(string key, object data) {
if (data == default) {
this.SetData<object>(key, data);
}
/// <inheritdoc />
public void SetData<T>(string key, T data) {
if (EqualityComparer<T>.Default.Equals(data, default)) {
if (this.data != null)
this.data.Remove(key);
} else {
@ -35,9 +45,9 @@ namespace MLEM.Data.Json {
}
/// <inheritdoc />
public IReadOnlyCollection<string> GetDataKeys() {
public IEnumerable<string> GetDataKeys() {
if (this.data == null)
return Array.Empty<string>();
return JsonTypeSafeGenericDataHolder.EmptyStrings;
return this.data.Keys;
}

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
using Newtonsoft.Json;
namespace MLEM.Data.Json {
@ -15,6 +14,7 @@ namespace MLEM.Data.Json {
/// <summary>
/// Returns this json type-safe wrapper's value as an <see cref="object"/>.
/// </summary>
[JsonIgnore]
public abstract object Value { get; }
/// <summary>
@ -29,30 +29,37 @@ namespace MLEM.Data.Json {
/// <summary>
/// Creates a new <see cref="JsonTypeSafeWrapper{T}"/> from the given value.
/// The type parameter of the returned wrapper will be equal to the <see cref="Type"/> of the <paramref name="value"/> passed.
/// If a <see cref="JsonTypeSafeWrapper{T}"/> for a specific type, known at comepile type, should be created, you can use <see cref="JsonTypeSafeWrapper{T}(T)"/>.
/// The type parameter of the returned wrapper will be equal to the <see cref="Type"/> of the <paramref name="value"/> passed, even if it is a subtype of <typeparamref name="T"/>.
/// If a <see cref="JsonTypeSafeWrapper{T}"/> for a specific type, known at compile type, should be created, you can use <see cref="JsonTypeSafeWrapper{T}(T)"/>.
/// </summary>
/// <param name="value">The value to wrap</param>
/// <returns>A <see cref="JsonTypeSafeWrapper{T}"/> with a type matching the type of <paramref name="value"/></returns>
public static JsonTypeSafeWrapper Of(object value) {
#if NET7_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.RequiresDynamicCode("The native code for this instantiation might not be available at runtime if the value's type is a subtype of T.")]
#endif
public static JsonTypeSafeWrapper Of<T>(T value) {
if (value.GetType() == typeof(T)) {
return new JsonTypeSafeWrapper<T>(value);
} else {
var type = typeof(JsonTypeSafeWrapper<>).MakeGenericType(value.GetType());
return (JsonTypeSafeWrapper) Activator.CreateInstance(type, value);
}
}
}
/// <inheritdoc />
[DataContract]
public class JsonTypeSafeWrapper<T> : JsonTypeSafeWrapper {
/// <inheritdoc />
public override object Value => this.value;
[DataMember]
[JsonProperty]
private readonly T value;
/// <summary>
/// Creates a new json type-safe wrapper instance that wraps the given <paramref name="value"/>.
/// If the type of the value is unknown at compile time, <see cref="JsonTypeSafeWrapper.Of"/> can be used instead.
/// If the type of the value is unknown at compile time, <see cref="JsonTypeSafeWrapper.Of{T}"/> can be used instead.
/// </summary>
/// <param name="value">The value to wrap</param>
public JsonTypeSafeWrapper(T value) {

View file

@ -4,6 +4,10 @@ using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
#if NET6_0_OR_GREATER
using System.Diagnostics.CodeAnalysis;
#endif
namespace MLEM.Data.Json {
/// <summary>
/// A <see cref="JsonConverter{T}"/> that doesn't actually serialize the object, but instead serializes the name given to it by the underlying <see cref="Dictionary{T,T}"/>.
@ -29,7 +33,11 @@ namespace MLEM.Data.Json {
/// </summary>
/// <param name="type">The type that the dictionary is declared in</param>
/// <param name="memberName">The name of the dictionary itself</param>
public StaticJsonConverter(Type type, string memberName) : this(StaticJsonConverter<T>.GetEntries(type, memberName)) {}
public StaticJsonConverter(
#if NET6_0_OR_GREATER
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)]
#endif
Type type, string memberName) : this(StaticJsonConverter<T>.GetEntries(type, memberName)) {}
/// <summary>Writes the JSON representation of the object.</summary>
/// <param name="writer">The <see cref="T:Newtonsoft.Json.JsonWriter" /> to write to.</param>
@ -56,7 +64,11 @@ namespace MLEM.Data.Json {
return ret;
}
private static Dictionary<string, T> GetEntries(Type type, string memberName) {
private static Dictionary<string, T> GetEntries(
#if NET6_0_OR_GREATER
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.NonPublicProperties | DynamicallyAccessedMemberTypes.PublicFields | DynamicallyAccessedMemberTypes.NonPublicFields)]
#endif
Type type, string memberName) {
const BindingFlags flags = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic;
var value = type.GetProperty(memberName, flags)?.GetValue(null) ?? type.GetField(memberName, flags)?.GetValue(null);
if (value == null)

View file

@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net452;netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
<RootNamespace>MLEM.Data</RootNamespace>
<DefineConstants>$(DefineConstants);FNA</DefineConstants>
<NoWarn>NU1701</NoWarn>
@ -27,13 +28,14 @@
<PackageReference Include="Lidgren.Network" Version="1.0.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1">
<PackageReference Include="Newtonsoft.Json" Version="13.0.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json.Bson" Version="1.0.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<ProjectReference Include="..\FNA\FNA.Core.csproj">
<ProjectReference Include="..\FNA\FNA.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
</ItemGroup>

View file

@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net452;netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
<NoWarn>NU1701</NoWarn>
</PropertyGroup>
@ -25,7 +26,7 @@
<PackageReference Include="Lidgren.Network" Version="1.0.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.1">
<PackageReference Include="Newtonsoft.Json" Version="13.0.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json.Bson" Version="1.0.2">

View file

@ -11,6 +11,9 @@ namespace MLEM.Data {
/// Before serializing and deserializing an object, each of the object's fields has to have a handler. New handlers can be added using <see cref="AddHandler{T}(System.Action{Lidgren.Network.NetBuffer,T},System.Func{Lidgren.Network.NetBuffer,T})"/> or <see cref="AddHandler{T}(Newtonsoft.Json.JsonSerializer)"/>.
/// </summary>
[Obsolete("Lidgren.Network support is deprecated. Consider using LiteNetLib or a custom implementation instead.")]
#if NET6_0_OR_GREATER
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Aot", "IL2070")]
#endif
public class NetBufferSerializer {
private readonly Dictionary<Type, Action<NetBuffer, object>> writeFunctions = new Dictionary<Type, Action<NetBuffer, object>>();

View file

@ -35,8 +35,8 @@ namespace MLEM.Data {
public TimeSpan LastTotalTime => this.LastCalculationTime + this.LastPackTime;
private readonly List<Request> texturesToPack = new List<Request>();
private readonly List<Request> alreadyPackedTextures = new List<Request>();
private readonly Dictionary<Point, Point> firstPossiblePosForSizeCache = new Dictionary<Point, Point>();
private readonly List<Request> packedTextures = new List<Request>();
private readonly Dictionary<Point, Point> firstPossiblePosForSize = new Dictionary<Point, Point>();
private readonly Dictionary<Texture2D, TextureData> dataCache = new Dictionary<Texture2D, TextureData>();
private readonly bool autoIncreaseMaxWidth;
private readonly bool forcePowerOfTwo;
@ -71,7 +71,7 @@ namespace MLEM.Data {
/// <param name="padding">The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding.</param>
/// <param name="padWithPixels">Whether the texture's padding should be filled with a copy of the texture's border, rather than transparent pixels. This value only has an effect if <paramref name="padding"/> is greater than 0.</param>
/// <param name="ignoreTransparent">Whether completely transparent texture regions in the <paramref name="atlas"/> should be ignored. If this is true, they will not be part of the <paramref name="result"/> collection either.</param>
/// <exception cref="InvalidOperationException">Thrown when trying to add data to a packer that has already been packed, or when trying to add a texture width a width greater than the defined max width.</exception>
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
public void Add(UniformTextureAtlas atlas, Action<Dictionary<Point, TextureRegion>> result, int padding = 0, bool padWithPixels = false, bool ignoreTransparent = false) {
var addedRegions = new List<TextureRegion>();
var resultRegions = new Dictionary<Point, TextureRegion>();
@ -104,7 +104,7 @@ namespace MLEM.Data {
/// <param name="result">The result callback which will receive the resulting texture regions.</param>
/// <param name="padding">The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding.</param>
/// <param name="padWithPixels">Whether the texture's padding should be filled with a copy of the texture's border, rather than transparent pixels. This value only has an effect if <paramref name="padding"/> is greater than 0.</param>
/// <exception cref="InvalidOperationException">Thrown when trying to add data to a packer that has already been packed, or when trying to add a texture width a width greater than the defined max width.</exception>
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
public void Add(DataTextureAtlas atlas, Action<Dictionary<string, TextureRegion>> result, int padding = 0, bool padWithPixels = false) {
var atlasRegions = atlas.RegionNames.ToArray();
var resultRegions = new Dictionary<string, TextureRegion>();
@ -125,7 +125,7 @@ namespace MLEM.Data {
/// <param name="result">The result callback which will receive the resulting texture region.</param>
/// <param name="padding">The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding.</param>
/// <param name="padWithPixels">Whether the texture's padding should be filled with a copy of the texture's border, rather than transparent pixels. This value only has an effect if <paramref name="padding"/> is greater than 0.</param>
/// <exception cref="InvalidOperationException">Thrown when trying to add data to a packer that has already been packed, or when trying to add a texture width a width greater than the defined max width.</exception>
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
public void Add(Texture2D texture, Action<TextureRegion> result, int padding = 0, bool padWithPixels = false) {
this.Add(new TextureRegion(texture), result, padding, padWithPixels);
}
@ -138,10 +138,8 @@ namespace MLEM.Data {
/// <param name="result">The result callback which will receive the resulting texture region.</param>
/// <param name="padding">The padding that the texture should have around itself. This can be useful if texture bleeding issues occur due to texture coordinate rounding.</param>
/// <param name="padWithPixels">Whether the texture's padding should be filled with a copy of the texture's border, rather than transparent pixels. This value only has an effect if <paramref name="padding"/> is greater than 0.</param>
/// <exception cref="InvalidOperationException">Thrown when trying to add data to a packer that has already been packed, or when trying to add a texture width a width greater than the defined max width.</exception>
/// <exception cref="InvalidOperationException">Thrown when trying to add a texture width a width greater than the defined max width.</exception>
public void Add(TextureRegion texture, Action<TextureRegion> result, int padding = 0, bool padWithPixels = false) {
if (this.PackedTexture != null)
throw new InvalidOperationException("Cannot add texture to a texture packer that is already packed");
var paddedWidth = texture.Width + 2 * padding;
if (paddedWidth > this.maxWidth) {
if (this.autoIncreaseMaxWidth) {
@ -154,68 +152,80 @@ namespace MLEM.Data {
}
/// <summary>
/// Packs all of the textures and texture regions added using <see cref="Add(Microsoft.Xna.Framework.Graphics.Texture2D,System.Action{MLEM.Textures.TextureRegion},int,bool)"/> into one texture.
/// The resulting texture will be stored in <see cref="PackedTexture"/>.
/// Packs all of the textures and texture regions added using <see cref="Add(Microsoft.Xna.Framework.Graphics.Texture2D,System.Action{MLEM.Textures.TextureRegion},int,bool)"/> into one texture, which can be retrieved using <see cref="PackedTexture"/>.
/// All of the result callbacks that were added will also be invoked.
/// This method can be called multiple times if regions are added after <see cref="Pack"/> has already been called. When doing so, result callbacks of previous regions may be invoked again if the resulting <see cref="PackedTexture"/> has to be resized to accommodate newly added regions.
/// </summary>
/// <param name="device">The graphics device to use for texture generation</param>
/// <exception cref="InvalidOperationException">Thrown when calling this method on a texture packer that has already been packed</exception>
public void Pack(GraphicsDevice device) {
if (this.PackedTexture != null)
throw new InvalidOperationException("Cannot pack a texture packer that is already packed");
// set pack areas for each request
// we pack larger textures first, so that smaller textures can fit in the gaps that larger ones leave
var stopwatch = Stopwatch.StartNew();
foreach (var request in this.texturesToPack.OrderByDescending(t => t.Texture.Width * t.Texture.Height)) {
request.PackedArea = this.FindFreeArea(request);
// if this is the first position that this request fit in, no other requests of the same size will find a position before it
this.firstPossiblePosForSizeCache[new Point(request.PackedArea.Width, request.PackedArea.Height)] = request.PackedArea.Location;
this.alreadyPackedTextures.Add(request);
this.firstPossiblePosForSize[new Point(request.PackedArea.Width, request.PackedArea.Height)] = request.PackedArea.Location;
this.packedTextures.Add(request);
}
stopwatch.Stop();
this.LastCalculationTime = stopwatch.Elapsed;
// figure out texture size and generate texture
var width = this.alreadyPackedTextures.Max(t => t.PackedArea.Right);
var height = this.alreadyPackedTextures.Max(t => t.PackedArea.Bottom);
// figure out texture size and regenerate texture if necessary
var width = this.packedTextures.Max(t => t.PackedArea.Right);
var height = this.packedTextures.Max(t => t.PackedArea.Bottom);
if (this.forcePowerOfTwo) {
width = RuntimeTexturePacker.ToPowerOfTwo(width);
height = RuntimeTexturePacker.ToPowerOfTwo(height);
}
if (this.forceSquare)
width = height = Math.Max(width, height);
// if we don't need to regenerate, we only need to add newly added regions
IEnumerable<Request> texturesToCopy = this.texturesToPack;
if (this.PackedTexture == null || this.PackedTexture.Width != width || this.PackedTexture.Height != height) {
this.PackedTexture?.Dispose();
this.PackedTexture = new Texture2D(device, width, height);
// if we need to regenerate, we need to copy all regions since the old ones were deleted
texturesToCopy = this.packedTextures;
}
// copy texture data onto the packed texture
stopwatch.Restart();
using (var data = this.PackedTexture.GetTextureData()) {
foreach (var request in this.alreadyPackedTextures)
foreach (var request in texturesToCopy)
this.CopyRegion(data, request);
}
stopwatch.Stop();
this.LastPackTime = stopwatch.Elapsed;
// invoke callbacks
foreach (var request in this.alreadyPackedTextures) {
// invoke callbacks for textures we copied
foreach (var request in texturesToCopy) {
var packedArea = request.PackedArea.Shrink(new Point(request.Padding, request.Padding));
request.Result.Invoke(new TextureRegion(this.PackedTexture, packedArea));
request.Result.Invoke(new TextureRegion(this.PackedTexture, packedArea) {
Pivot = request.Texture.Pivot,
Name = request.Texture.Name,
Source = request.Texture
});
if (this.disposeTextures)
request.Texture.Texture.Dispose();
}
this.ClearTempCollections();
this.texturesToPack.Clear();
this.dataCache.Clear();
}
/// <summary>
/// Resets this texture packer, disposing its <see cref="PackedTexture"/> and readying it to be re-used
/// Resets this texture packer entirely, disposing its <see cref="PackedTexture"/>, clearing all previously added requests, and readying it to be re-used.
/// </summary>
public void Reset() {
this.PackedTexture?.Dispose();
this.PackedTexture = null;
this.LastCalculationTime = TimeSpan.Zero;
this.LastPackTime = TimeSpan.Zero;
this.ClearTempCollections();
this.texturesToPack.Clear();
this.packedTextures.Clear();
this.firstPossiblePosForSize.Clear();
this.dataCache.Clear();
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
@ -228,12 +238,12 @@ namespace MLEM.Data {
size.X += request.Padding * 2;
size.Y += request.Padding * 2;
var pos = this.firstPossiblePosForSizeCache.TryGetValue(size, out var first) ? first : Point.Zero;
var pos = this.firstPossiblePosForSize.TryGetValue(size, out var first) ? first : Point.Zero;
var lowestY = int.MaxValue;
while (true) {
var intersected = false;
var area = new Rectangle(pos.X, pos.Y, size.X, size.Y);
foreach (var tex in this.alreadyPackedTextures) {
foreach (var tex in this.packedTextures) {
if (tex.PackedArea.Intersects(area)) {
pos.X = tex.PackedArea.Right;
// when we move down, we want to move down by the smallest intersecting texture's height
@ -264,7 +274,7 @@ namespace MLEM.Data {
srcColor = Color.Transparent;
} else {
// otherwise, we just use the closest pixel that is actually in bounds, causing the border pixels to be doubled up
var src = new Point((int) MathHelper.Clamp(x, 0, request.Texture.Width - 1), (int) MathHelper.Clamp(y, 0, request.Texture.Height - 1));
var src = new Point((int) MathHelper.Clamp(x, 0F, request.Texture.Width - 1), (int) MathHelper.Clamp(y, 0F, request.Texture.Height - 1));
srcColor = data[request.Texture.Position + src];
}
destination[location + new Point(x, y)] = srcColor;
@ -293,13 +303,6 @@ namespace MLEM.Data {
return true;
}
private void ClearTempCollections() {
this.texturesToPack.Clear();
this.alreadyPackedTextures.Clear();
this.firstPossiblePosForSizeCache.Clear();
this.dataCache.Clear();
}
private static int ToPowerOfTwo(int value) {
var ret = 1;
while (ret < value)

View file

@ -40,5 +40,53 @@ namespace MLEM.Extended.Extensions {
return rect.ToMlem().Penetrate(other.ToMlem(), out normal, out penetration);
}
/// <summary>
/// Returns how far between the given <paramref name="range"/>'s <see cref="Range{T}.Min"/> and <see cref="Range{T}.Max"/> value the given <paramref name="value"/> is, as a number between 0 and 1.
/// Note that, if the <paramref name="value"/> is outside the given <paramref name="range"/>, a correct proportional value outside the 0 to 1 range will still be returned.
/// This method is the reverse action of <see cref="FromPercentage(MonoGame.Extended.Range{float},float)"/>.
/// </summary>
/// <param name="range">The range to query.</param>
/// <param name="value">The value to query.</param>
/// <returns>The percentage.</returns>
public static float GetPercentage(this Range<float> range, float value) {
return (value - range.Min) / (range.Max - range.Min);
}
/// <summary>
/// Returns how far between the given <paramref name="range"/>'s <see cref="Range{T}.Min"/> and <see cref="Range{T}.Max"/> value the given <paramref name="value"/> is, as a number between 0 and 1.
/// Note that, if the <paramref name="value"/> is outside the given <paramref name="range"/>, a correct proportional value outside the 0 to 1 range will still be returned.
/// This method is the reverse action of <see cref="FromPercentage(MonoGame.Extended.Range{int},float)"/>.
/// </summary>
/// <param name="range">The range to query.</param>
/// <param name="value">The value to query.</param>
/// <returns>The percentage.</returns>
public static float GetPercentage(this Range<int> range, float value) {
return (value - range.Min) / (range.Max - range.Min);
}
/// <summary>
/// Returns a value within the given <paramref name="range"/>'s <see cref="Range{T}.Min"/> and <see cref="Range{T}.Max"/> values based on the given <paramref name="percentage"/> into the range.
/// Note that, if the <paramref name="percentage"/> is outside the 0 to 1 range, a correct value outside the <paramref name="range"/> will still be returned.
/// This method is the reverse action of <see cref="GetPercentage(MonoGame.Extended.Range{float},float)"/>.
/// </summary>
/// <param name="range">The range to query.</param>
/// <param name="percentage">The percentage to query.</param>
/// <returns>The value.</returns>
public static float FromPercentage(this Range<float> range, float percentage) {
return (range.Max - range.Min) * percentage + range.Min;
}
/// <summary>
/// Returns a value within the given <paramref name="range"/>'s <see cref="Range{T}.Min"/> and <see cref="Range{T}.Max"/> values based on the given <paramref name="percentage"/> into the range.
/// Note that, if the <paramref name="percentage"/> is outside the 0 to 1 range, a correct value outside the <paramref name="range"/> will still be returned.
/// This method is the reverse action of <see cref="GetPercentage(MonoGame.Extended.Range{int},float)"/>.
/// </summary>
/// <param name="range">The range to query.</param>
/// <param name="percentage">The percentage to query.</param>
/// <returns>The value.</returns>
public static float FromPercentage(this Range<int> range, float percentage) {
return (range.Max - range.Min) * percentage + range.Min;
}
}
}

View file

@ -32,14 +32,14 @@ namespace MLEM.Extended.Font {
}
/// <inheritdoc />
protected override float MeasureChar(char c) {
var region = this.Font.GetCharacterRegion(c);
protected override float MeasureCharacter(int codePoint) {
var region = this.Font.GetCharacterRegion(codePoint);
return region != null ? new Vector2(region.XAdvance, region.Height).X : 0;
}
/// <inheritdoc />
protected override void DrawChar(SpriteBatch batch, string cString, Vector2 position, Color color, float rotation, Vector2 scale, SpriteEffects effects, float layerDepth) {
batch.DrawString(this.Font, cString, position, color, rotation, Vector2.Zero, scale, effects, layerDepth);
protected override void DrawCharacter(SpriteBatch batch, int codePoint, string character, Vector2 position, Color color, float rotation, Vector2 scale, SpriteEffects effects, float layerDepth) {
batch.DrawString(this.Font, character, position, color, rotation, Vector2.Zero, scale, effects, layerDepth);
}
}

View file

@ -1,7 +1,6 @@
using FontStashSharp;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Extensions;
using MLEM.Font;
namespace MLEM.Extended.Font {
@ -33,13 +32,13 @@ namespace MLEM.Extended.Font {
}
/// <inheritdoc />
protected override float MeasureChar(char c) {
return this.Font.MeasureString(c.ToCachedString()).X;
protected override float MeasureCharacter(int codePoint) {
return this.Font.MeasureString(CodePointSource.ToString(codePoint)).X;
}
/// <inheritdoc />
protected override void DrawChar(SpriteBatch batch, string cString, Vector2 position, Color color, float rotation, Vector2 scale, SpriteEffects effects, float layerDepth) {
this.Font.DrawText(batch, cString, position, color, scale, rotation, Vector2.Zero, layerDepth);
protected override void DrawCharacter(SpriteBatch batch, int codePoint, string character, Vector2 position, Color color, float rotation, Vector2 scale, SpriteEffects effects, float layerDepth) {
this.Font.DrawText(batch, character, position, color, scale, rotation, Vector2.Zero, layerDepth);
}
}

View file

@ -1,10 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
<RootNamespace>MLEM.Extended</RootNamespace>
<DefineConstants>$(DefineConstants);FNA</DefineConstants>
<NoWarn>NU1702</NoWarn>
</PropertyGroup>
<PropertyGroup>
@ -22,10 +24,10 @@
<ItemGroup>
<ProjectReference Include="..\MLEM\MLEM.FNA.csproj" />
<ProjectReference Include="..\FontStashSharp\src\XNA\FontStashSharp.FNA.Core.csproj">
<ProjectReference Include="..\FontStashSharp\src\XNA\FontStashSharp.FNA.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
<ProjectReference Include="..\FNA\FNA.Core.csproj">
<ProjectReference Include="..\FNA\FNA.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>

View file

@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>
<PropertyGroup>
@ -26,7 +27,7 @@
<PackageReference Include="MonoGame.Extended.Tiled" Version="3.8.0">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="FontStashSharp.MonoGame" Version="1.1.6">
<PackageReference Include="FontStashSharp.MonoGame" Version="1.2.8">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.0.1641">

View file

@ -16,9 +16,11 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests.FNA", "Tests\Tests.FN
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MLEM.Extended.FNA", "MLEM.Extended\MLEM.Extended.FNA.csproj", "{A5B22930-DF4B-4A62-93ED-A6549F7B666B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FNA.Core", "FNA\FNA.Core.csproj", "{06459F72-CEAA-4B45-B2B1-708FC28D04F8}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FNA", "FNA\FNA.csproj", "{35253CE1-C864-4CD3-8249-4D1319748E8F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FontStashSharp.FNA.Core", "FontStashSharp\src\XNA\FontStashSharp.FNA.Core.csproj", "{0B410591-3AED-4C82-A07A-516FF493709B}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FontStashSharp.FNA", "FontStashSharp\src\XNA\FontStashSharp.FNA.csproj", "{39249E92-EBF2-4951-A086-AB4951C3CCE1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FNA.Core", "FNA\FNA.Core.csproj", "{458FFA5E-A1C4-4B23-A5D8-259385FEECED}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -58,13 +60,17 @@ Global
{A5B22930-DF4B-4A62-93ED-A6549F7B666B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5B22930-DF4B-4A62-93ED-A6549F7B666B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5B22930-DF4B-4A62-93ED-A6549F7B666B}.Release|Any CPU.Build.0 = Release|Any CPU
{06459F72-CEAA-4B45-B2B1-708FC28D04F8}.Debug|Any CPU.ActiveCfg = Debug|x64
{06459F72-CEAA-4B45-B2B1-708FC28D04F8}.Debug|Any CPU.Build.0 = Debug|x64
{06459F72-CEAA-4B45-B2B1-708FC28D04F8}.Release|Any CPU.ActiveCfg = Release|x64
{06459F72-CEAA-4B45-B2B1-708FC28D04F8}.Release|Any CPU.Build.0 = Release|x64
{0B410591-3AED-4C82-A07A-516FF493709B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0B410591-3AED-4C82-A07A-516FF493709B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0B410591-3AED-4C82-A07A-516FF493709B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0B410591-3AED-4C82-A07A-516FF493709B}.Release|Any CPU.Build.0 = Release|Any CPU
{35253CE1-C864-4CD3-8249-4D1319748E8F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{35253CE1-C864-4CD3-8249-4D1319748E8F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{35253CE1-C864-4CD3-8249-4D1319748E8F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{35253CE1-C864-4CD3-8249-4D1319748E8F}.Release|Any CPU.Build.0 = Release|Any CPU
{39249E92-EBF2-4951-A086-AB4951C3CCE1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{39249E92-EBF2-4951-A086-AB4951C3CCE1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{39249E92-EBF2-4951-A086-AB4951C3CCE1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{39249E92-EBF2-4951-A086-AB4951C3CCE1}.Release|Any CPU.Build.0 = Release|Any CPU
{458FFA5E-A1C4-4B23-A5D8-259385FEECED}.Debug|Any CPU.ActiveCfg = Debug|x64
{458FFA5E-A1C4-4B23-A5D8-259385FEECED}.Debug|Any CPU.Build.0 = Debug|x64
{458FFA5E-A1C4-4B23-A5D8-259385FEECED}.Release|Any CPU.ActiveCfg = Release|x64
{458FFA5E-A1C4-4B23-A5D8-259385FEECED}.Release|Any CPU.Build.0 = Release|x64
EndGlobalSection
EndGlobal

View file

@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net452;netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
<RootNamespace>MLEM.Startup</RootNamespace>
<DefineConstants>$(DefineConstants);FNA</DefineConstants>
</PropertyGroup>
@ -21,11 +22,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Coroutine" Version="2.1.3" />
<PackageReference Include="Coroutine" Version="2.1.4" />
<ProjectReference Include="..\MLEM.Ui\MLEM.Ui.FNA.csproj" />
<ProjectReference Include="..\MLEM\MLEM.FNA.csproj" />
<ProjectReference Include="..\FNA\FNA.Core.csproj">
<ProjectReference Include="..\FNA\FNA.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
</ItemGroup>

View file

@ -1,9 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net452;netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>
<PropertyGroup>
@ -19,7 +20,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Coroutine" Version="2.1.3" />
<PackageReference Include="Coroutine" Version="2.1.4" />
<ProjectReference Include="..\MLEM.Ui\MLEM.Ui.csproj" />
<ProjectReference Include="..\MLEM\MLEM.csproj" />

View file

@ -69,9 +69,9 @@ namespace MLEM.Startup {
this.GraphicsDeviceManager = new GraphicsDeviceManager(this) {
PreferredBackBufferWidth = windowWidth,
PreferredBackBufferHeight = windowHeight,
#if !FNA
#if !FNA
HardwareModeSwitch = false
#endif
#endif
};
this.Window.AllowUserResizing = true;
this.Content.RootDirectory = "Content";
@ -116,9 +116,9 @@ namespace MLEM.Startup {
this.PreDraw?.Invoke(this, gameTime);
CoroutineHandler.RaiseEvent(CoroutineEvents.PreDraw);
#pragma warning disable CS0618
#pragma warning disable CS0618
this.UiSystem.DrawEarly(gameTime, this.SpriteBatch);
#pragma warning restore CS0618
#pragma warning restore CS0618
this.DoDraw(gameTime);
this.UiSystem.Draw(gameTime, this.SpriteBatch);

View file

@ -1,11 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net452;netstandard2.0;net6.0</TargetFrameworks>
<IncludeContentInPack>true</IncludeContentInPack>
<IncludeBuildOutput>false</IncludeBuildOutput>
<ContentTargetFolders>content</ContentTargetFolders>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsTrimmable>true</IsTrimmable>
<NoWarn>NU5128</NoWarn>
</PropertyGroup>

View file

@ -1,3 +1,4 @@
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Graphics;
@ -51,8 +52,12 @@ namespace MLEM.Ui.Elements {
/// <summary>
/// Set this property to true to mark the button as disabled.
/// A disabled button cannot be moused over, selected or pressed.
/// If this value changes often, consider using <see cref="AutoDisableCondition"/>.
/// </summary>
public virtual bool IsDisabled { get; set; }
public virtual bool IsDisabled {
get => this.isDisabled || this.AutoDisableCondition?.Invoke(this) == true;
set => this.isDisabled = value;
}
/// <summary>
/// Whether this button's <see cref="Text"/> should be truncated if it exceeds this button's width.
/// Defaults to false.
@ -69,12 +74,19 @@ namespace MLEM.Ui.Elements {
/// If this is true, <see cref="CanBeSelected"/> will be able to return true even if <see cref="IsDisabled"/> is true.
/// </summary>
public bool CanSelectDisabled;
/// <summary>
/// An optional function that can be used to modify the result of <see cref="IsDisabled"/> automatically based on a user-defined condition. This removes the need to disable a button based on a condition in <see cref="Element.OnUpdated"/> or manually.
/// Note that, if <see cref="IsDisabled"/>'s underlying value is set to <see langword="true"/> using <see cref="set_IsDisabled"/>, this function's result will be ignored.
/// </summary>
public Func<Button, bool> AutoDisableCondition;
/// <inheritdoc />
public override bool CanBeSelected => base.CanBeSelected && (this.CanSelectDisabled || !this.IsDisabled);
/// <inheritdoc />
public override bool CanBePressed => base.CanBePressed && !this.IsDisabled;
private bool isDisabled;
/// <summary>
/// Creates a new button with the given settings
/// </summary>

View file

@ -46,7 +46,13 @@ namespace MLEM.Ui.Elements {
/// <summary>
/// The width of the space between this checkbox and its <see cref="Label"/>
/// </summary>
public StyleProp<float> TextOffsetX;
public StyleProp<float> TextOffsetX {
get => this.textOffsetX;
set {
this.textOffsetX = value;
this.SetAreaDirty();
}
}
/// <summary>
/// Whether or not this checkbox is currently checked.
/// </summary>
@ -80,6 +86,7 @@ namespace MLEM.Ui.Elements {
public override bool CanBePressed => base.CanBePressed && !this.IsDisabled;
private bool isChecked;
private StyleProp<float> textOffsetX;
/// <summary>
/// Creates a new checkbox with the given settings

View file

@ -42,7 +42,16 @@ namespace MLEM.Ui.Elements {
});
this.OnAreaUpdated += e => this.Panel.PositionOffset = new Vector2(0, e.Area.Height / this.Scale);
this.OnOpenedOrClosed += e => this.Priority = this.IsOpen ? 10000 : 0;
this.OnPressed += e => this.IsOpen = !this.IsOpen;
this.OnPressed += e => {
this.IsOpen = !this.IsOpen;
// close other dropdowns in the same root when we open
if (this.IsOpen) {
this.Root.Element.AndChildren(o => {
if (o != this && o is Dropdown d && d.IsOpen)
d.IsOpen = false;
});
}
};
this.GetGamepadNextElement = (dir, usualNext) => {
// Force navigate down to our first child if we're open
if (this.IsOpen && dir == Direction2.Down)

View file

@ -10,9 +10,9 @@ using MLEM.Extensions;
using MLEM.Graphics;
using MLEM.Input;
using MLEM.Misc;
using MLEM.Sound;
using MLEM.Textures;
using MLEM.Ui.Style;
using SoundEffectInfo = MLEM.Sound.SoundEffectInfo;
namespace MLEM.Ui.Elements {
/// <summary>
@ -31,7 +31,7 @@ namespace MLEM.Ui.Elements {
/// </summary>
public UiSystem System {
get => this.system;
internal set {
private set {
this.system = value;
this.Controls = value?.Controls;
this.Style = this.Style.OrStyle(value?.Style);
@ -50,7 +50,7 @@ namespace MLEM.Ui.Elements {
/// This element's <see cref="RootElement"/>.
/// Note that this value is set even if this element has a <see cref="Parent"/>. To get the element that represents the root element, use <see cref="RootElement.Element"/>.
/// </summary>
public RootElement Root { get; internal set; }
public RootElement Root { get; private set; }
/// <summary>
/// The scale that this ui element renders with
/// </summary>
@ -71,9 +71,10 @@ namespace MLEM.Ui.Elements {
/// The size of this element, where X represents the width and Y represents the height.
/// If the x or y value of the size is between 0 and 1, the size will be seen as a percentage of its parent's size rather than as an absolute value.
/// If the x (or y) value of the size is negative, the width (or height) is seen as a percentage of the element's resulting height (or width).
/// Additionally, if auto-sizing is used, <see cref="AutoSizeAddedAbsolute"/> can be set to add or subtract an absolute value from the automatically calculated size.
/// </summary>
/// <example>
/// The following example combines both types of percentage-based sizing.
/// The following example, ignoring <see cref="Scale"/>, combines both types of percentage-based sizing.
/// If this element is inside of a <see cref="Parent"/> whose width is 20, this element's width will be set to <c>0.5 * 20 = 10</c>, and its height will be set to <c>2.5 * 10 = 25</c>.
/// <code>
/// element.Size = new Vector2(0.5F, -2.5F);
@ -93,6 +94,26 @@ namespace MLEM.Ui.Elements {
/// </summary>
public Vector2 ScaledSize => this.size * this.Scale;
/// <summary>
/// If auto-sizing is used by setting <see cref="Size"/> less than or equal to 1, this property allows adding or subtracting an additional, absolute value from the automatically calculated size.
/// If this element is not using auto-sizing, this property is ignored.
/// </summary>
/// <example>
/// Ignoring <see cref="Scale"/>, if this element's <see cref="Size"/> is set to <c>0.5, 0.75</c> and its <see cref="Parent"/> has a size of <c>200, 100</c>, then an added absolute size of <c>-50, 25</c> will result in a final <see cref="Area"/> size of <c>0.5 * 200 - 50, 0.75 * 100 + 25</c>, or <c>50, 100</c>.
/// </example>
public Vector2 AutoSizeAddedAbsolute {
get => this.autoSizeAddedAbsolute;
set {
if (this.autoSizeAddedAbsolute == value)
return;
this.autoSizeAddedAbsolute = value;
this.SetAreaDirty();
}
}
/// <summary>
/// The <see cref="AutoSizeAddedAbsolute"/>, but with <see cref="Scale"/> applied.
/// </summary>
public Vector2 ScaledAutoSizeAddedAbsolute => this.autoSizeAddedAbsolute * this.Scale;
/// <summary>
/// This element's offset from its default position, which is dictated by its <see cref="Anchor"/>.
/// Note that, depending on the side that the element is anchored to, this offset moves it in a different direction.
/// </summary>
@ -185,10 +206,10 @@ namespace MLEM.Ui.Elements {
/// The call that this element should make to <see cref="SpriteBatch"/> to begin drawing.
/// Note that, when this is non-null, a new <c>SpriteBatch.Begin</c> call is used for this element.
/// </summary>
#pragma warning disable CS0618
#pragma warning disable CS0618
[Obsolete("BeginImpl is deprecated. You can create a custom element class and override Draw instead.")]
public BeginDelegate BeginImpl;
#pragma warning restore CS0618
#pragma warning restore CS0618
/// <summary>
/// Set this field to false to disallow the element from being selected.
/// An unselectable element is skipped by automatic navigation and its <see cref="OnSelected"/> callback will never be called.
@ -212,34 +233,82 @@ namespace MLEM.Ui.Elements {
/// <summary>
/// Set this field to false to cause auto-anchored siblings to ignore this element as a possible anchor point.
/// </summary>
public virtual bool CanAutoAnchorsAttach { get; set; } = true;
public virtual bool CanAutoAnchorsAttach {
get => this.canAutoAnchorsAttach;
set {
if (this.canAutoAnchorsAttach != value) {
this.canAutoAnchorsAttach = value;
this.SetAreaDirty();
}
}
}
/// <summary>
/// Set this field to true to cause this element's width to be automatically calculated based on the area that its <see cref="Children"/> take up.
/// To use this element's <see cref="Size"/>'s X coordinate as a minimum or maximum width rather than ignoring it, set <see cref="TreatSizeAsMinimum"/> or <see cref="TreatSizeAsMaximum"/> to true.
/// </summary>
public virtual bool SetWidthBasedOnChildren { get; set; }
public virtual bool SetWidthBasedOnChildren {
get => this.setWidthBasedOnChildren;
set {
if (this.setWidthBasedOnChildren != value) {
this.setWidthBasedOnChildren = value;
this.SetAreaDirty();
}
}
}
/// <summary>
/// Set this field to true to cause this element's height to be automatically calculated based on the area that its <see cref="Children"/> take up.
/// To use this element's <see cref="Size"/>'s Y coordinate as a minimum or maximum height rather than ignoring it, set <see cref="TreatSizeAsMinimum"/> or <see cref="TreatSizeAsMaximum"/> to true.
/// </summary>
public virtual bool SetHeightBasedOnChildren { get; set; }
public virtual bool SetHeightBasedOnChildren {
get => this.setHeightBasedOnChildren;
set {
if (this.setHeightBasedOnChildren != value) {
this.setHeightBasedOnChildren = value;
this.SetAreaDirty();
}
}
}
/// <summary>
/// If this field is set to true, and <see cref="SetWidthBasedOnChildren"/> or <see cref="SetHeightBasedOnChildren"/> are enabled, the resulting width or height will always be greather than or equal to this element's <see cref="Size"/>.
/// For example, if an element's <see cref="Size"/>'s Y coordinate is set to 20, but there is only one child with a height of 10 in it, the element's height would be shrunk to 10 if this value was false, but would remain at 20 if it was true.
/// Note that this value only has an effect if <see cref="SetWidthBasedOnChildren"/> or <see cref="SetHeightBasedOnChildren"/> are enabled.
/// </summary>
public virtual bool TreatSizeAsMinimum { get; set; }
public virtual bool TreatSizeAsMinimum {
get => this.treatSizeAsMinimum;
set {
if (this.treatSizeAsMinimum != value) {
this.treatSizeAsMinimum = value;
this.SetAreaDirty();
}
}
}
/// <summary>
/// If this field is set to true, and <see cref="SetWidthBasedOnChildren"/> or <see cref="SetHeightBasedOnChildren"/>are enabled, the resulting width or height weill always be less than or equal to this element's <see cref="Size"/>.
/// Note that this value only has an effect if <see cref="SetWidthBasedOnChildren"/> or <see cref="SetHeightBasedOnChildren"/> are enabled.
/// </summary>
public virtual bool TreatSizeAsMaximum { get; set; }
public virtual bool TreatSizeAsMaximum {
get => this.treatSizeAsMaximum;
set {
if (this.treatSizeAsMaximum != value) {
this.treatSizeAsMaximum = value;
this.SetAreaDirty();
}
}
}
/// <summary>
/// Set this field to true to cause this element's final display area to never exceed that of its <see cref="Parent"/>.
/// If the resulting area is too large, the size of this element is shrunk to fit the target area.
/// This can be useful if an element should fill the remaining area of a parent exactly.
/// </summary>
public virtual bool PreventParentSpill { get; set; }
public virtual bool PreventParentSpill {
get => this.preventParentSpill;
set {
if (this.preventParentSpill != value) {
this.preventParentSpill = value;
this.SetAreaDirty();
}
}
}
/// <summary>
/// The transparency (alpha value) that this element is rendered with.
/// Note that, when <see cref="Draw(Microsoft.Xna.Framework.GameTime,Microsoft.Xna.Framework.Graphics.SpriteBatch,float,MLEM.Graphics.SpriteBatchContext)"/> is called, this alpha value is multiplied with the <see cref="Parent"/>'s alpha value and passed down to this element's <see cref="Children"/>.
@ -387,16 +456,27 @@ namespace MLEM.Ui.Elements {
public GamepadNextElementCallback GetGamepadNextElement;
/// <summary>
/// Event that is called when a child is added to this element using <see cref="AddChild{T}"/>
/// Note that, while this event is only called for immediate children of this element, <see cref="RootElement.OnElementAdded"/> is called for all children and grandchildren.
/// </summary>
public OtherElementCallback OnChildAdded;
/// <summary>
/// Event that is called when a child is removed from this element using <see cref="RemoveChild"/>
/// Event that is called when a child is removed from this element using <see cref="RemoveChild"/>.
/// Note that, while this event is only called for immediate children of this element, <see cref="RootElement.OnElementRemoved"/> is called for all children and grandchildren.
/// </summary>
public OtherElementCallback OnChildRemoved;
/// <summary>
/// Event that is called when this element's <see cref="Dispose"/> method is called, which also happens in <see cref="Finalize"/>.
/// Event that is called when this element is added to a <see cref="UiSystem"/>, that is, when this element's <see cref="System"/> is set to a non-<see langword="null"/> value.
/// </summary>
public GenericCallback OnAddedToUi;
/// <summary>
/// Event that is called when this element is removed from a <see cref="UiSystem"/>, that is, when this element's <see cref="System"/> is set to <see langword="null"/>.
/// </summary>
public GenericCallback OnRemovedFromUi;
/// <summary>
/// Event that is called when this element's <see cref="Dispose"/> method is called.
/// This event is useful for unregistering global event handlers when this object should be destroyed.
/// </summary>
[Obsolete("OnDisposed will be removed in a future update. To unregister custom event handlers, use OnRemovedFromUi instead.")]
public GenericCallback OnDisposed;
/// <summary>
@ -421,7 +501,7 @@ namespace MLEM.Ui.Elements {
/// The <see cref="ChildPaddedArea"/> of this element's <see cref="Parent"/>, or the <see cref="UiSystem.Viewport"/> if this element has no parent.
/// This value is the one that is passed to <see cref="CalcActualSize"/> during <see cref="ForceUpdateArea"/>.
/// </summary>
protected RectangleF ParentArea => this.Parent?.ChildPaddedArea ?? (RectangleF) this.system.Viewport;
protected RectangleF ParentArea => this.Parent?.ChildPaddedArea ?? (RectangleF) this.System.Viewport;
private readonly List<Element> children = new List<Element>();
private readonly Stopwatch stopwatch = new Stopwatch();
@ -430,6 +510,7 @@ namespace MLEM.Ui.Elements {
private UiSystem system;
private Anchor anchor;
private Vector2 size;
private Vector2 autoSizeAddedAbsolute;
private Vector2 offset;
private RectangleF area;
private bool isHidden;
@ -437,6 +518,12 @@ namespace MLEM.Ui.Elements {
private StyleProp<UiStyle> style;
private StyleProp<Padding> childPadding;
private bool canBeSelected = true;
private bool canAutoAnchorsAttach = true;
private bool setWidthBasedOnChildren;
private bool setHeightBasedOnChildren;
private bool treatSizeAsMinimum;
private bool treatSizeAsMaximum;
private bool preventParentSpill;
/// <summary>
/// Creates a new element with the given anchor and size and sets up some default event reactions.
@ -448,14 +535,15 @@ namespace MLEM.Ui.Elements {
this.size = size;
this.Children = new ReadOnlyCollection<Element>(this.children);
this.GetTabNextElement += (backward, next) => next;
this.GetGamepadNextElement += (dir, next) => next;
this.GetTabNextElement = (backward, next) => next;
this.GetGamepadNextElement = (dir, next) => next;
this.SetAreaDirty();
this.SetSortedChildrenDirty();
}
/// <inheritdoc />
[Obsolete("Dispose will be removed in a future update. To unregister custom event handlers, use OnRemovedFromUi instead.")]
~Element() {
this.Dispose();
}
@ -472,12 +560,8 @@ namespace MLEM.Ui.Elements {
index = this.children.Count;
this.children.Insert(index, element);
element.Parent = this;
element.AndChildren(e => {
e.Root = this.Root;
e.System = this.System;
this.Root?.InvokeOnElementAdded(e);
this.OnChildAdded?.Invoke(this, e);
});
element.AndChildren(e => e.AddedToUi(this.System, this.Root));
this.OnChildAdded?.Invoke(this, element);
this.SetSortedChildrenDirty();
element.SetAreaDirty();
return element;
@ -495,12 +579,8 @@ namespace MLEM.Ui.Elements {
// upwards to us if the element is auto-positioned
element.SetAreaDirty();
element.Parent = null;
element.AndChildren(e => {
e.Root = null;
e.System = null;
this.Root?.InvokeOnElementRemoved(e);
this.OnChildRemoved?.Invoke(this, e);
});
element.AndChildren(e => e.RemovedFromUi());
this.OnChildRemoved?.Invoke(this, element);
this.SetSortedChildrenDirty();
}
@ -567,7 +647,7 @@ namespace MLEM.Ui.Elements {
/// </summary>
public virtual void ForceUpdateArea() {
this.AreaDirty = false;
if (this.IsHidden)
if (this.IsHidden || this.System == null)
return;
// if the parent's area is dirty, it would get updated anyway when querying its ChildPaddedArea,
// which would cause our ForceUpdateArea code to be run twice, so we only update our parent instead
@ -633,7 +713,7 @@ namespace MLEM.Ui.Elements {
break;
}
if (this.Anchor >= Anchor.AutoLeft) {
if (this.Anchor.IsAuto()) {
Element previousChild;
if (this.Anchor == Anchor.AutoInline || this.Anchor == Anchor.AutoInlineIgnoreOverflow) {
previousChild = this.GetOlderSibling(e => !e.IsHidden && e.CanAutoAnchorsAttach);
@ -687,11 +767,13 @@ namespace MLEM.Ui.Elements {
if (this.SetHeightBasedOnChildren) {
var lowest = this.GetLowestChild(e => !e.IsHidden);
if (lowest != null) {
if (lowest.Anchor.IsTopAligned()) {
autoSize.Y = lowest.UnscrolledArea.Bottom - pos.Y + this.ScaledChildPadding.Bottom;
} else {
autoSize.Y = lowest.UnscrolledArea.Height + this.ScaledChildPadding.Height;
}
foundChild = lowest;
} else {
if (this.Children.Any(e => !e.IsHidden))
throw new InvalidOperationException($"{this} with root {this.Root.Name} sets its height based on children but it only has visible children anchored too low ({string.Join(", ", this.Children.Where(c => !c.IsHidden).Select(c => c.Anchor))})");
autoSize.Y = 0;
}
}
@ -699,11 +781,13 @@ namespace MLEM.Ui.Elements {
if (this.SetWidthBasedOnChildren) {
var rightmost = this.GetRightmostChild(e => !e.IsHidden);
if (rightmost != null) {
if (rightmost.Anchor.IsLeftAligned()) {
autoSize.X = rightmost.UnscrolledArea.Right - pos.X + this.ScaledChildPadding.Right;
} else {
autoSize.X = rightmost.UnscrolledArea.Width + this.ScaledChildPadding.Width;
}
foundChild = rightmost;
} else {
if (this.Children.Any(e => !e.IsHidden))
throw new InvalidOperationException($"{this} with root {this.Root.Name} sets its width based on children but it only has visible children anchored too far right ({string.Join(", ", this.Children.Where(c => !c.IsHidden).Select(c => c.Anchor))})");
autoSize.X = 0;
}
}
@ -717,15 +801,13 @@ namespace MLEM.Ui.Elements {
// we want to leave some leeway to prevent float rounding causing an infinite loop
if (!autoSize.Equals(this.UnscrolledArea.Size, Element.Epsilon)) {
recursion++;
if (recursion >= 16) {
if (recursion >= 16)
throw new ArithmeticException($"The area of {this} with root {this.Root.Name} has recursively updated too often. Does its child {foundChild} contain any conflicting auto-sizing settings?");
} else {
UpdateDisplayArea(autoSize);
}
}
}
}
}
/// <summary>
/// Sets this element's <see cref="Area"/> to the given <see cref="RectangleF"/> and invokes the <see cref="UiSystem.OnElementAreaUpdated"/> event.
@ -749,12 +831,12 @@ namespace MLEM.Ui.Elements {
/// <returns>The actual size of this element, taking <see cref="Scale"/> into account</returns>
protected virtual Vector2 CalcActualSize(RectangleF parentArea) {
var ret = new Vector2(
this.size.X > 1 ? this.ScaledSize.X : parentArea.Width * this.size.X,
this.size.Y > 1 ? this.ScaledSize.Y : parentArea.Height * this.size.Y);
this.size.X > 1 ? this.ScaledSize.X : parentArea.Width * this.size.X + this.ScaledAutoSizeAddedAbsolute.X,
this.size.Y > 1 ? this.ScaledSize.Y : parentArea.Height * this.size.Y + this.ScaledAutoSizeAddedAbsolute.Y);
if (this.size.X < 0)
ret.X = -this.size.X * ret.Y;
ret.X = -this.size.X * ret.Y + this.ScaledAutoSizeAddedAbsolute.X;
if (this.size.Y < 0)
ret.Y = -this.size.Y * ret.X;
ret.Y = -this.size.Y * ret.X + this.ScaledAutoSizeAddedAbsolute.Y;
return ret;
}
@ -773,13 +855,15 @@ namespace MLEM.Ui.Elements {
/// <returns>The lowest element, or null if no such element exists</returns>
public Element GetLowestChild(Func<Element, bool> condition = null) {
Element lowest = null;
var lowestX = float.MinValue;
foreach (var child in this.Children) {
if (condition != null && !condition(child))
continue;
if (child.Anchor > Anchor.TopRight && child.Anchor < Anchor.AutoLeft)
continue;
if (lowest == null || child.UnscrolledArea.Bottom >= lowest.UnscrolledArea.Bottom)
var x = !child.Anchor.IsTopAligned() ? child.UnscrolledArea.Height : child.UnscrolledArea.Bottom;
if (x >= lowestX) {
lowest = child;
lowestX = x;
}
}
return lowest;
}
@ -791,13 +875,15 @@ namespace MLEM.Ui.Elements {
/// <returns>The rightmost element, or null if no such element exists</returns>
public Element GetRightmostChild(Func<Element, bool> condition = null) {
Element rightmost = null;
var rightmostX = float.MinValue;
foreach (var child in this.Children) {
if (condition != null && !condition(child))
continue;
if (child.Anchor < Anchor.AutoLeft && child.Anchor != Anchor.TopLeft && child.Anchor != Anchor.CenterLeft && child.Anchor != Anchor.BottomLeft)
continue;
if (rightmost == null || child.UnscrolledArea.Right >= rightmost.UnscrolledArea.Right)
var x = !child.Anchor.IsLeftAligned() ? child.UnscrolledArea.Width : child.UnscrolledArea.Right;
if (x >= rightmostX) {
rightmost = child;
rightmostX = x;
}
}
return rightmost;
}
@ -951,7 +1037,7 @@ namespace MLEM.Ui.Elements {
/// <param name="alpha">The alpha to draw this element and its children with</param>
/// <param name="context">The sprite batch context to use for drawing</param>
public void DrawTransformed(GameTime time, SpriteBatch batch, float alpha, SpriteBatchContext context) {
#pragma warning disable CS0618
#pragma warning disable CS0618
var customDraw = this.BeginImpl != null || this.Transform != Matrix.Identity;
var transformed = context;
transformed.TransformMatrix = this.Transform * transformed.TransformMatrix;
@ -962,12 +1048,12 @@ namespace MLEM.Ui.Elements {
// begin our own draw call
batch.Begin(transformed);
}
#pragma warning restore CS0618
#pragma warning restore CS0618
// draw content in custom begin call
#pragma warning disable CS0618
#pragma warning disable CS0618
this.Draw(time, batch, alpha, transformed.BlendState, transformed.SamplerState, transformed.DepthStencilState, transformed.Effect, transformed.TransformMatrix);
#pragma warning restore CS0618
#pragma warning restore CS0618
if (this.System != null)
this.System.Metrics.Draws++;
@ -1011,9 +1097,9 @@ namespace MLEM.Ui.Elements {
foreach (var child in this.GetRelevantChildren()) {
if (!child.IsHidden) {
#pragma warning disable CS0618
#pragma warning disable CS0618
child.DrawTransformed(time, batch, alpha * child.DrawAlpha, context.BlendState, context.SamplerState, context.DepthStencilState, context.Effect, context.TransformMatrix);
#pragma warning restore CS0618
#pragma warning restore CS0618
}
}
}
@ -1058,6 +1144,7 @@ namespace MLEM.Ui.Elements {
}
/// <summary>Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.</summary>
[Obsolete("Dispose will be removed in a future update. To unregister custom event handlers, use OnRemovedFromUi instead.")]
public virtual void Dispose() {
this.OnDisposed?.Invoke(this);
GC.SuppressFinalize(this);
@ -1119,7 +1206,7 @@ namespace MLEM.Ui.Elements {
/// <param name="grandchild">Whether the <paramref name="child"/> is a grandchild of this element, rather than a direct child.</param>
protected virtual void OnChildAreaDirty(Element child, bool grandchild) {
if (!grandchild) {
if (child.Anchor >= Anchor.AutoLeft || this.SetWidthBasedOnChildren || this.SetHeightBasedOnChildren)
if (child.Anchor.IsAuto() || child.PreventParentSpill || this.SetWidthBasedOnChildren || this.SetHeightBasedOnChildren)
this.SetAreaDirty();
}
this.Parent?.OnChildAreaDirty(child, true);
@ -1147,6 +1234,31 @@ namespace MLEM.Ui.Elements {
return this.TransformInverse(position);
}
/// <summary>
/// Called when this element is added to a <see cref="UiSystem"/> and, optionally, a given <see cref="RootElement"/>.
/// This method is called in <see cref="AddChild{T}"/> and <see cref="UiSystem.Add"/>.
/// </summary>
/// <param name="system">The ui system to add to.</param>
/// <param name="root">The root element to add to.</param>
protected internal virtual void AddedToUi(UiSystem system, RootElement root) {
this.Root = root;
this.System = system;
this.OnAddedToUi?.Invoke(this);
root?.InvokeOnElementAdded(this);
}
/// <summary>
/// Called when this element is removed from a <see cref="UiSystem"/> and <see cref="RootElement"/>.
/// This method is called in <see cref="RemoveChild"/> and <see cref="UiSystem.Remove"/>.
/// </summary>
protected internal virtual void RemovedFromUi() {
var root = this.Root;
this.Root = null;
this.System = null;
this.OnRemovedFromUi?.Invoke(this);
root?.InvokeOnElementRemoved(this);
}
/// <summary>
/// A delegate used for the <see cref="Element.OnTextInput"/> event.
/// </summary>

View file

@ -68,12 +68,31 @@ namespace MLEM.Ui.Elements {
for (var i = 0; i < amount; i++) {
var anchor = i == amount - 1 ? Anchor.AutoInlineIgnoreOverflow : Anchor.AutoInline;
cols[i] = new Group(anchor, new Vector2(totalSize.X / amount, totalSize.Y), setHeightBasedOnChildren);
if (parent != null)
parent.AddChild(cols[i]);
parent?.AddChild(cols[i]);
}
return cols;
}
/// <summary>
/// Creates an array of groups with a fixed width and height that can be used to create a grid with equally sized boxes.
/// </summary>
/// <param name="parent">The element the groups should be added to, can be <see langword="null"/>.</param>
/// <param name="totalSize">The total size that the grid should take up.</param>
/// <param name="width">The width of the grid, or the amount of columns it should have.</param>
/// <param name="height">The height of the grid, or the amount of rows it should have.</param>
/// <returns>The created grid.</returns>
public static Group[,] MakeGrid(Element parent, Vector2 totalSize, int width, int height) {
var grid = new Group[width, height];
for (var y = 0; y < height; y++) {
for (var x = 0; x < width; x++) {
var anchor = x == 0 ? Anchor.AutoLeft : Anchor.AutoInlineIgnoreOverflow;
grid[x, y] = new Group(anchor, new Vector2(totalSize.X / width, totalSize.Y / height), false);
parent?.AddChild(grid[x, y]);
}
}
return grid;
}
/// <summary>
/// Creates a <see cref="TextField"/> with a + and a - button next to it, to allow for easy number input.
/// </summary>
@ -214,5 +233,32 @@ namespace MLEM.Ui.Elements {
return tooltip;
}
/// <summary>
/// Returns whether the given <see cref="Anchor"/> is automatic. The anchors <see cref="Anchor.AutoLeft"/>, <see cref="Anchor.AutoCenter"/>, <see cref="Anchor.AutoRight"/>, <see cref="Anchor.AutoInline"/> and <see cref="Anchor.AutoInlineIgnoreOverflow"/> will return true.
/// </summary>
/// <param name="anchor">The anchor to query.</param>
/// <returns>Whether the given anchor is automatic.</returns>
public static bool IsAuto(this Anchor anchor) {
return anchor == Anchor.AutoLeft || anchor == Anchor.AutoCenter || anchor == Anchor.AutoRight || anchor == Anchor.AutoInline || anchor == Anchor.AutoInlineIgnoreOverflow;
}
/// <summary>
/// Returns whether the given <see cref="Anchor"/> is left-aligned for the purpose of <see cref="Element.GetRightmostChild"/>. The anchors <see cref="Anchor.TopLeft"/>, <see cref="Anchor.CenterLeft"/>, <see cref="Anchor.BottomLeft"/>, <see cref="Anchor.AutoLeft"/>, <see cref="Anchor.AutoInline"/> and <see cref="Anchor.AutoInlineIgnoreOverflow"/> will return true.
/// </summary>
/// <param name="anchor">The anchor to query.</param>
/// <returns>Whether the given anchor is left-aligned.</returns>
public static bool IsLeftAligned(this Anchor anchor) {
return anchor == Anchor.TopLeft || anchor == Anchor.CenterLeft || anchor == Anchor.BottomLeft || anchor == Anchor.AutoLeft || anchor == Anchor.AutoInline || anchor == Anchor.AutoInlineIgnoreOverflow;
}
/// <summary>
/// Returns whether the given <see cref="Anchor"/> is top-aligned for the purpose of <see cref="Element.GetLowestChild"/>. The anchors <see cref="Anchor.TopLeft"/>, <see cref="Anchor.TopCenter"/>, <see cref="Anchor.TopRight"/>, <see cref="Anchor.AutoLeft"/>, <see cref="Anchor.AutoCenter"/>, <see cref="Anchor.AutoRight"/>, <see cref="Anchor.AutoInline"/> and <see cref="Anchor.AutoInlineIgnoreOverflow"/> will return true.
/// </summary>
/// <param name="anchor">The anchor to query.</param>
/// <returns>Whether the given anchor is top-aligned.</returns>
public static bool IsTopAligned(this Anchor anchor) {
return anchor == Anchor.TopLeft || anchor == Anchor.TopCenter || anchor == Anchor.TopRight || anchor == Anchor.AutoLeft || anchor == Anchor.AutoCenter || anchor == Anchor.AutoRight || anchor == Anchor.AutoInline || anchor == Anchor.AutoInlineIgnoreOverflow;
}
}
}

View file

@ -1,11 +1,13 @@
using System;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Extensions;
using MLEM.Graphics;
using MLEM.Misc;
using MLEM.Textures;
using MLEM.Ui.Style;
#if FNA
using MLEM.Extensions;
#endif
namespace MLEM.Ui.Elements {
/// <summary>
@ -28,17 +30,13 @@ namespace MLEM.Ui.Elements {
/// </summary>
public TextureRegion Texture {
get {
if (this.GetTextureCallback != null)
this.Texture = this.GetTextureCallback(this);
return this.texture;
var ret = this.GetTextureCallback?.Invoke(this) ?? this.texture;
this.CheckTextureChange(ret);
return ret;
}
set {
if (this.texture != value) {
this.texture = value;
this.IsHidden = this.texture == null;
if (this.scaleToImage)
this.SetAreaDirty();
}
this.CheckTextureChange(value);
}
}
/// <summary>
@ -73,8 +71,12 @@ namespace MLEM.Ui.Elements {
/// </summary>
public float ImageRotation;
/// <inheritdoc />
public override bool IsHidden => base.IsHidden || this.Texture == null;
private bool scaleToImage;
private TextureRegion texture;
private TextureRegion lastTexture;
/// <summary>
/// Creates a new image with the given settings
@ -121,6 +123,15 @@ namespace MLEM.Ui.Elements {
base.Draw(time, batch, alpha, context);
}
private void CheckTextureChange(TextureRegion newTexture) {
if (this.lastTexture == newTexture)
return;
var nullChanged = this.lastTexture == null != (newTexture == null);
this.lastTexture = newTexture;
if (nullChanged || this.scaleToImage)
this.SetAreaDirty();
}
/// <summary>
/// A delegate method used for <see cref="Image.GetTextureCallback"/>
/// </summary>

View file

@ -46,7 +46,13 @@ namespace MLEM.Ui.Elements {
/// <summary>
/// The amount of pixels of room there should be between the <see cref="ScrollBar"/> and the rest of the content
/// </summary>
public StyleProp<float> ScrollBarOffset;
public StyleProp<float> ScrollBarOffset {
get => this.scrollBarOffset;
set {
this.scrollBarOffset = value;
this.SetAreaDirty();
}
}
private readonly List<Element> relevantChildren = new List<Element>();
private readonly bool scrollOverflow;
@ -54,6 +60,7 @@ namespace MLEM.Ui.Elements {
private RenderTarget2D renderTarget;
private bool relevantChildrenDirty;
private float scrollBarChildOffset;
private StyleProp<float> scrollBarOffset;
/// <summary>
/// Creates a new panel with the given settings.
@ -84,8 +91,7 @@ namespace MLEM.Ui.Elements {
return;
if (e == null || !e.GetParentTree().Contains(this))
return;
var firstChild = this.Children.First(c => c != this.ScrollBar);
this.ScrollBar.CurrentValue = (e.Area.Center.Y - this.Area.Height / 2 - firstChild.Area.Top) / e.Scale + this.ChildPadding.Value.Height / 2;
this.ScrollToElement(e);
};
this.AddChild(this.ScrollBar);
}
@ -98,7 +104,7 @@ namespace MLEM.Ui.Elements {
if (this.SetHeightBasedOnChildren)
throw new NotSupportedException("A panel can't both set height based on children and scroll overflow");
foreach (var child in this.Children) {
if (child != this.ScrollBar && child.Anchor < Anchor.AutoLeft)
if (child != this.ScrollBar && !child.Anchor.IsAuto())
throw new NotSupportedException($"A panel that handles overflow can't contain non-automatic anchors ({child})");
if (child is Panel panel && panel.scrollOverflow)
throw new NotSupportedException($"A panel that scrolls overflow cannot contain another panel that scrolls overflow ({child})");
@ -115,19 +121,6 @@ namespace MLEM.Ui.Elements {
this.ScrollSetup();
}
private void ScrollChildren() {
if (!this.scrollOverflow)
return;
var offset = new Vector2(0, -this.ScrollBar.CurrentValue);
// we ignore false grandchildren so that the children of the scroll bar stay in place
foreach (var child in this.GetChildren(c => c != this.ScrollBar, true, true)) {
if (!child.ScrollOffset.Equals(offset, Element.Epsilon)) {
child.ScrollOffset = offset;
this.relevantChildrenDirty = true;
}
}
}
/// <inheritdoc />
public override void ForceUpdateSortedChildren() {
base.ForceUpdateSortedChildren();
@ -147,26 +140,6 @@ namespace MLEM.Ui.Elements {
base.RemoveChildren(e => e != this.ScrollBar && (condition == null || condition(e)));
}
/// <inheritdoc />
protected override IList<Element> GetRelevantChildren() {
var relevant = base.GetRelevantChildren();
if (this.scrollOverflow) {
if (this.relevantChildrenDirty)
this.ForceUpdateRelevantChildren();
relevant = this.relevantChildren;
}
return relevant;
}
/// <inheritdoc />
protected override void OnChildAreaDirty(Element child, bool grandchild) {
base.OnChildAreaDirty(child, grandchild);
// we only need to scroll when a grandchild changes, since all of our children are forced
// to be auto-anchored and so will automatically propagate their changes up to us
if (grandchild)
this.ScrollChildren();
}
/// <inheritdoc />
public override void Draw(GameTime time, SpriteBatch batch, float alpha, SpriteBatchContext context) {
// draw children onto the render target if we have one
@ -211,11 +184,23 @@ namespace MLEM.Ui.Elements {
return base.GetElementUnderPos(position);
}
private RectangleF GetRenderTargetArea() {
var area = this.ChildPaddedArea;
area.X = this.DisplayArea.X;
area.Width = this.DisplayArea.Width;
return area;
/// <summary>
/// Scrolls this panel's <see cref="ScrollBar"/> to the given <see cref="Element"/> in such a way that its center is positioned in the center of this panel.
/// </summary>
/// <param name="element">The element to scroll to.</param>
public void ScrollToElement(Element element) {
this.ScrollToElement(element.Area.Center.Y);
}
/// <summary>
/// Scrolls this panel's <see cref="ScrollBar"/> to the given <paramref name="elementY"/> coordinate in such a way that the coordinate is positioned in the center of this panel.
/// </summary>
/// <param name="elementY">The y coordinate to scroll to, which should have this element's <see cref="Element.Scale"/> applied.</param>
public void ScrollToElement(float elementY) {
var firstChild = this.Children.FirstOrDefault(c => c != this.ScrollBar);
if (firstChild == null)
return;
this.ScrollBar.CurrentValue = (elementY - this.Area.Height / 2 - firstChild.Area.Top) / this.Scale + this.ChildPadding.Value.Height / 2;
}
/// <inheritdoc />
@ -229,26 +214,58 @@ namespace MLEM.Ui.Elements {
this.SetScrollBarStyle();
}
/// <inheritdoc />
protected override IList<Element> GetRelevantChildren() {
var relevant = base.GetRelevantChildren();
if (this.scrollOverflow) {
if (this.relevantChildrenDirty)
this.ForceUpdateRelevantChildren();
relevant = this.relevantChildren;
}
return relevant;
}
/// <inheritdoc />
protected override void OnChildAreaDirty(Element child, bool grandchild) {
base.OnChildAreaDirty(child, grandchild);
// we only need to scroll when a grandchild changes, since all of our children are forced
// to be auto-anchored and so will automatically propagate their changes up to us
if (grandchild)
this.ScrollChildren();
}
/// <inheritdoc />
protected internal override void RemovedFromUi() {
base.RemovedFromUi();
// we dispose our render target when removing so that it doesn't cause a memory leak
// if we're added back afterwards, it'll be recreated in ScrollSetup anyway
this.renderTarget?.Dispose();
this.renderTarget = null;
}
/// <summary>
/// Prepares the panel for auto-scrolling, creating the render target and setting up the scroll bar's maximum value.
/// </summary>
protected virtual void ScrollSetup() {
if (!this.scrollOverflow || this.IsHidden)
return;
// if there is only one child, then we have just the scroll bar
if (this.Children.Count == 1)
return;
// the "real" first child is the scroll bar, which we want to ignore
var firstChild = this.Children.First(c => c != this.ScrollBar);
float childrenHeight;
if (this.Children.Count > 1) {
var firstChild = this.Children.FirstOrDefault(c => c != this.ScrollBar);
var lowestChild = this.GetLowestChild(c => c != this.ScrollBar && !c.IsHidden);
var childrenHeight = lowestChild.Area.Bottom - firstChild.Area.Top;
childrenHeight = lowestChild.Area.Bottom - firstChild.Area.Top;
} else {
// if we only have one child (the scroll bar), then the children take up no visual height
childrenHeight = 0;
}
// the max value of the scrollbar is the amount of non-scaled pixels taken up by overflowing components
// the max value of the scroll bar is the amount of non-scaled pixels taken up by overflowing components
var scrollBarMax = (childrenHeight - this.ChildPaddedArea.Height) / this.Scale;
if (!this.ScrollBar.MaxValue.Equals(scrollBarMax, Element.Epsilon)) {
this.ScrollBar.MaxValue = scrollBarMax;
this.relevantChildrenDirty = true;
}
// update child padding based on whether the scroll bar is visible
var childOffset = this.ScrollBar.IsHidden ? 0 : this.ScrollerSize.Value.X + this.ScrollBarOffset;
@ -257,7 +274,6 @@ namespace MLEM.Ui.Elements {
this.scrollBarChildOffset = childOffset;
this.SetAreaDirty();
}
}
// the scroller height has the same relation to the scroll bar height as the visible area has to the total height of the panel's content
var scrollerHeight = Math.Min(this.ChildPaddedArea.Height / childrenHeight / this.Scale, 1) * this.ScrollBar.Area.Height;
@ -265,25 +281,18 @@ namespace MLEM.Ui.Elements {
// update the render target
var targetArea = (Rectangle) this.GetRenderTargetArea();
if (targetArea.Width <= 0 || targetArea.Height <= 0)
if (targetArea.Width <= 0 || targetArea.Height <= 0) {
this.renderTarget?.Dispose();
this.renderTarget = null;
return;
}
if (this.renderTarget == null || targetArea.Width != this.renderTarget.Width || targetArea.Height != this.renderTarget.Height) {
if (this.renderTarget != null)
this.renderTarget.Dispose();
this.renderTarget?.Dispose();
this.renderTarget = targetArea.IsEmpty ? null : new RenderTarget2D(this.System.Game.GraphicsDevice, targetArea.Width, targetArea.Height);
this.relevantChildrenDirty = true;
}
}
/// <inheritdoc />
public override void Dispose() {
if (this.renderTarget != null) {
this.renderTarget.Dispose();
this.renderTarget = null;
}
base.Dispose();
}
private void SetScrollBarStyle() {
if (this.ScrollBar == null)
return;
@ -310,5 +319,21 @@ namespace MLEM.Ui.Elements {
}
}
private RectangleF GetRenderTargetArea() {
var area = this.ChildPaddedArea;
area.X = this.DisplayArea.X;
area.Width = this.DisplayArea.Width;
return area;
}
private void ScrollChildren() {
if (!this.scrollOverflow)
return;
// we ignore false grandchildren so that the children of the scroll bar stay in place
foreach (var child in this.GetChildren(c => c != this.ScrollBar, true, true))
child.ScrollOffset.Y = -this.ScrollBar.CurrentValue;
this.relevantChildrenDirty = true;
}
}
}

View file

@ -32,7 +32,13 @@ namespace MLEM.Ui.Elements {
/// <summary>
/// The tokenized version of the <see cref="Text"/>
/// </summary>
public TokenizedString TokenizedText { get; private set; }
public TokenizedString TokenizedText {
get {
this.CheckTextChange();
this.TokenizeIfNecessary();
return this.tokenizedText;
}
}
/// <summary>
/// The color that the text will be rendered with
/// </summary>
@ -41,48 +47,78 @@ namespace MLEM.Ui.Elements {
/// The scale that the text will be rendered with.
/// To add a multiplier rather than changing the scale directly, use <see cref="TextScaleMultiplier"/>.
/// </summary>
public StyleProp<float> TextScale;
public StyleProp<float> TextScale {
get => this.textScale;
set {
this.textScale = value;
this.SetTextDirty();
}
}
/// <summary>
/// A multiplier that will be applied to <see cref="TextScale"/>.
/// To change the text scale itself, use <see cref="TextScale"/>.
/// </summary>
public float TextScaleMultiplier = 1;
public float TextScaleMultiplier {
get => this.textScaleMultiplier;
set {
if (this.textScaleMultiplier != value) {
this.textScaleMultiplier = value;
this.SetTextDirty();
}
}
}
/// <summary>
/// The text to render inside of this paragraph.
/// Use <see cref="GetTextCallback"/> if the text changes frequently.
/// </summary>
public string Text {
get {
this.QueryTextCallback();
return this.text;
this.CheckTextChange();
return this.displayedText;
}
set {
if (this.text != value) {
this.text = value;
this.SetTextDirty();
var force = string.IsNullOrWhiteSpace(this.text);
if (this.forceHide != force) {
this.forceHide = force;
this.SetAreaDirty();
}
}
this.explicitlySetText = value;
this.CheckTextChange();
}
}
/// <summary>
/// If this paragraph should automatically adjust its width based on the width of the text within it
/// </summary>
public bool AutoAdjustWidth;
public bool AutoAdjustWidth {
get => this.autoAdjustWidth;
set {
if (this.autoAdjustWidth != value) {
this.autoAdjustWidth = value;
this.SetAreaDirty();
}
}
}
/// <summary>
/// Whether this paragraph should be truncated instead of split if the displayed <see cref="Text"/>'s width exceeds the provided width.
/// When the string is truncated, the <see cref="Ellipsis"/> is added to its end.
/// </summary>
public bool TruncateIfLong;
public bool TruncateIfLong {
get => this.truncateIfLong;
set {
if (this.truncateIfLong != value) {
this.truncateIfLong = value;
this.SetAlignSplitDirty();
}
}
}
/// <summary>
/// The ellipsis characters to use if <see cref="TruncateIfLong"/> is enabled and the string is truncated.
/// If this is set to an empty string, no ellipsis will be attached to the truncated string.
/// </summary>
public string Ellipsis = "...";
public string Ellipsis {
get => this.ellipsis;
set {
if (this.ellipsis != value) {
this.ellipsis = value;
this.SetAlignSplitDirty();
}
}
}
/// <summary>
/// An event that gets called when this paragraph's <see cref="Text"/> is queried.
/// Use this event for setting this paragraph's text if it changes frequently.
@ -105,12 +141,20 @@ namespace MLEM.Ui.Elements {
}
/// <inheritdoc />
public override bool IsHidden => base.IsHidden || this.forceHide;
public override bool IsHidden => base.IsHidden || string.IsNullOrWhiteSpace(this.Text);
private string text;
private string displayedText;
private string explicitlySetText;
private StyleProp<TextAlignment> alignment;
private StyleProp<GenericFont> regularFont;
private bool forceHide;
private StyleProp<float> textScale;
private TokenizedString tokenizedText;
private float? lastAlignSplitWidth;
private float? lastAlignSplitScale;
private string ellipsis = "...";
private bool truncateIfLong;
private float textScaleMultiplier = 1;
private bool autoAdjustWidth;
/// <summary>
/// Creates a new paragraph with the given settings.
@ -134,17 +178,17 @@ namespace MLEM.Ui.Elements {
/// <inheritdoc />
protected override Vector2 CalcActualSize(RectangleF parentArea) {
var size = base.CalcActualSize(parentArea);
this.ParseText(size);
var textSize = this.TokenizedText.Measure(this.RegularFont) * this.TextScale * this.TextScaleMultiplier * this.Scale;
this.CheckTextChange();
this.TokenizeIfNecessary();
this.AlignAndSplitIfNecessary(size);
var textSize = this.tokenizedText.GetArea(Vector2.Zero, this.TextScale * this.TextScaleMultiplier * this.Scale).Size;
return new Vector2(this.AutoAdjustWidth ? textSize.X + this.ScaledPadding.Width : size.X, textSize.Y + this.ScaledPadding.Height);
}
/// <inheritdoc />
public override void Update(GameTime time) {
this.QueryTextCallback();
this.TokenizedText?.Update(time);
base.Update(time);
if (this.TokenizedText != null)
this.TokenizedText.Update(time);
}
/// <inheritdoc />
@ -165,45 +209,22 @@ namespace MLEM.Ui.Elements {
this.Alignment = this.Alignment.OrStyle(style.TextAlignment);
}
/// <summary>
/// Parses this paragraph's <see cref="Text"/> into <see cref="TokenizedText"/>.
/// Additionally, this method adds any <see cref="Link"/> elements for tokenized links in the text.
/// </summary>
/// <param name="size">The paragraph's default size</param>
protected virtual void ParseText(Vector2 size) {
if (this.TokenizedText == null) {
// tokenize the text
this.TokenizedText = this.System.TextFormatter.Tokenize(this.RegularFont, this.Text, this.Alignment);
// add links to the paragraph
this.RemoveChildren(c => c is Link);
foreach (var link in this.TokenizedText.Tokens.Where(t => t.AppliedCodes.Any(c => c is LinkCode)))
this.AddChild(new Link(Anchor.TopLeft, link, this.TextScale * this.TextScaleMultiplier));
}
var width = size.X - this.ScaledPadding.Width;
var scale = this.TextScale * this.TextScaleMultiplier * this.Scale;
if (this.TruncateIfLong) {
this.TokenizedText.Truncate(this.RegularFont, width, scale, this.Ellipsis, this.Alignment);
} else {
this.TokenizedText.Split(this.RegularFont, width, scale, this.Alignment);
}
}
/// <summary>
/// A helper method that causes the <see cref="TokenizedText"/> to be reset.
/// Additionally, <see cref="Element.SetAreaDirty"/> if this paragraph's area has changed enough to warrant it, or if it has any <see cref="Link"/> children.
/// </summary>
protected void SetTextDirty() {
this.TokenizedText = null;
private void SetTextDirty() {
this.tokenizedText = null;
// only set our area dirty if our size changed as a result of this action
if (!this.AreaDirty && !this.CalcActualSize(this.ParentArea).Equals(this.DisplayArea.Size, Element.Epsilon))
this.SetAreaDirty();
}
private void QueryTextCallback() {
if (this.GetTextCallback != null)
this.Text = this.GetTextCallback(this);
private void CheckTextChange() {
var newText = this.GetTextCallback?.Invoke(this) ?? this.explicitlySetText;
if (this.displayedText == newText)
return;
var emptyChanged = string.IsNullOrWhiteSpace(this.displayedText) != string.IsNullOrWhiteSpace(newText);
this.displayedText = newText;
if (emptyChanged)
this.SetAreaDirty();
this.SetTextDirty();
}
private float GetAlignmentOffset() {
@ -216,6 +237,41 @@ namespace MLEM.Ui.Elements {
return 0;
}
private void TokenizeIfNecessary() {
if (this.tokenizedText != null)
return;
// tokenize the text
this.tokenizedText = this.System.TextFormatter.Tokenize(this.RegularFont, this.Text, this.Alignment);
this.SetAlignSplitDirty();
// add links to the paragraph
this.RemoveChildren(c => c is Link);
foreach (var link in this.tokenizedText.Tokens.Where(t => t.AppliedCodes.Any(c => c is LinkCode)))
this.AddChild(new Link(Anchor.TopLeft, link, this.TextScale * this.TextScaleMultiplier));
}
private void AlignAndSplitIfNecessary(Vector2 size) {
var width = size.X - this.ScaledPadding.Width;
var scale = this.TextScale * this.TextScaleMultiplier * this.Scale;
if (this.lastAlignSplitWidth == width && this.lastAlignSplitScale == scale)
return;
this.lastAlignSplitWidth = width;
this.lastAlignSplitScale = scale;
if (this.TruncateIfLong) {
this.tokenizedText.Truncate(this.RegularFont, width, scale, this.Ellipsis, this.Alignment);
} else {
this.tokenizedText.Split(this.RegularFont, width, scale, this.Alignment);
}
}
private void SetAlignSplitDirty() {
this.lastAlignSplitWidth = null;
this.lastAlignSplitScale = null;
}
/// <summary>
/// A delegate method used for <see cref="Paragraph.GetTextCallback"/>
/// </summary>

View file

@ -3,12 +3,14 @@ using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input.Touch;
using MLEM.Extensions;
using MLEM.Graphics;
using MLEM.Input;
using MLEM.Misc;
using MLEM.Textures;
using MLEM.Ui.Style;
#if FNA
using MLEM.Extensions;
#endif
namespace MLEM.Ui.Elements {
/// <summary>
@ -31,6 +33,16 @@ namespace MLEM.Ui.Elements {
/// The texture of this scroll bar's scroller indicator
/// </summary>
public StyleProp<NinePatch> ScrollerTexture;
/// <summary>
/// Whether smooth scrolling should be enabled for this scroll bar.
/// Smooth scrolling causes the <see cref="CurrentValue"/> to change gradually rather than instantly when scrolling.
/// </summary>
public StyleProp<bool> SmoothScrolling;
/// <summary>
/// The factor with which <see cref="SmoothScrolling"/> happens.
/// </summary>
public StyleProp<float> SmoothScrollFactor;
/// <summary>
/// The scroller's width and height
/// </summary>
@ -84,7 +96,7 @@ namespace MLEM.Ui.Elements {
/// <summary>
/// This property is true while the user scrolls on the scroll bar using the mouse or touch input
/// </summary>
public bool IsBeingScrolled => this.isMouseHeld || this.isDragging || this.isTouchHeld;
public bool IsBeingScrolled => this.isMouseScrolling || this.isMouseDragging || this.isTouchDragging || this.isTouchScrolling;
/// <summary>
/// This field determines if this scroll bar should automatically be hidden from a <see cref="Panel"/> if there aren't enough children to allow for scrolling.
/// </summary>
@ -97,18 +109,14 @@ namespace MLEM.Ui.Elements {
!this.Horizontal ? 0 : this.CurrentValue / this.maxValue * (this.DisplayArea.Width - this.ScrollerSize.X * this.Scale),
this.Horizontal ? 0 : this.CurrentValue / this.maxValue * (this.DisplayArea.Height - this.ScrollerSize.Y * this.Scale));
/// <summary>
/// Whether smooth scrolling should be enabled for this scroll bar.
/// Smooth scrolling causes the <see cref="CurrentValue"/> to change gradually rather than instantly when scrolling.
/// Whether this scroll bar should allow dragging the mouse over its attached <see cref="Panel"/>'s content while holding the left mouse button to scroll, similarly to how scrolling using touch input works.
/// </summary>
public StyleProp<bool> SmoothScrolling;
/// <summary>
/// The factor with which <see cref="SmoothScrolling"/> happens.
/// </summary>
public StyleProp<float> SmoothScrollFactor;
public bool MouseDragScrolling;
private bool isMouseHeld;
private bool isDragging;
private bool isTouchHeld;
private bool isMouseScrolling;
private bool isMouseDragging;
private bool isTouchScrolling;
private bool isTouchDragging;
private float maxValue;
private float scrollAdded;
private float currValue;
@ -139,18 +147,29 @@ namespace MLEM.Ui.Elements {
// MOUSE INPUT
var moused = this.Controls.MousedElement;
if (moused == this && this.Input.WasMouseButtonUp(MouseButton.Left) && this.Input.IsMouseButtonDown(MouseButton.Left)) {
this.isMouseHeld = true;
var wasMouseUp = this.Input.WasUp(MouseButton.Left);
var isMouseDown = this.Input.IsDown(MouseButton.Left);
if (moused == this && wasMouseUp && isMouseDown) {
this.isMouseScrolling = true;
this.scrollStartOffset = this.TransformInverseAll(this.Input.ViewportMousePosition.ToVector2()) - this.ScrollerPosition;
} else if (this.isMouseHeld && !this.Input.IsMouseButtonDown(MouseButton.Left)) {
this.isMouseHeld = false;
} else if (!isMouseDown) {
this.isMouseScrolling = false;
}
if (this.isMouseHeld)
if (this.isMouseScrolling)
this.ScrollToPos(this.TransformInverseAll(this.Input.ViewportMousePosition.ToVector2()));
if (!this.Horizontal && moused != null && (moused == this.Parent || moused.GetParentTree().Contains(this.Parent))) {
if (!this.Horizontal) {
if (moused != null && (moused == this.Parent || moused.GetParentTree().Contains(this.Parent))) {
var scroll = this.Input.LastScrollWheel - this.Input.ScrollWheel;
if (scroll != 0)
this.CurrentValue += this.StepPerScroll * Math.Sign(scroll);
if (this.MouseDragScrolling && moused != this && wasMouseUp && isMouseDown)
this.isMouseDragging = true;
}
if (!isMouseDown)
this.isMouseDragging = false;
if (this.isMouseDragging)
this.CurrentValue -= (this.Input.MousePosition.Y - this.Input.LastMousePosition.Y) / this.Scale;
}
// TOUCH INPUT
@ -160,29 +179,29 @@ namespace MLEM.Ui.Elements {
// if the element under the drag's start position is on top of the panel, start dragging
var touched = this.Parent.GetElementUnderPos(this.TransformInverseAll(drag.Position));
if (touched != null && touched != this)
this.isDragging = true;
this.isTouchDragging = true;
// if we're dragging at all, then move the scroller
if (this.isDragging)
if (this.isTouchDragging)
this.CurrentValue -= drag.Delta.Y / this.Scale;
} else {
this.isDragging = false;
this.isTouchDragging = false;
}
}
if (this.Input.ViewportTouchState.Count <= 0) {
// if no touch has occured this tick, then reset the variable
this.isTouchHeld = false;
this.isTouchScrolling = false;
} else {
foreach (var loc in this.Input.ViewportTouchState) {
var pos = this.TransformInverseAll(loc.Position);
// if we just started touching and are on top of the scroller, then we should start scrolling
if (this.DisplayArea.Contains(pos) && !loc.TryGetPreviousLocation(out _)) {
this.isTouchHeld = true;
this.isTouchScrolling = true;
this.scrollStartOffset = pos - this.ScrollerPosition;
break;
}
// scroll no matter if we're on the scroller right now
if (this.isTouchHeld)
if (this.isTouchScrolling)
this.ScrollToPos(pos);
}
}

View file

@ -7,7 +7,9 @@ using MLEM.Input;
using MLEM.Misc;
using MLEM.Textures;
using MLEM.Ui.Style;
#if NETSTANDARD2_0_OR_GREATER || NET6_0_OR_GREATER
using TextCopy;
#endif
namespace MLEM.Ui.Elements {
/// <summary>
@ -31,6 +33,14 @@ namespace MLEM.Ui.Elements {
/// <inheritdoc cref="TextInput.FileNames"/>
public static readonly Rule FileNames = (field, add) => TextInput.FileNames(field.textInput, add);
#if NETSTANDARD2_0_OR_GREATER || NET6_0_OR_GREATER
/// <summary>
/// An event that is raised when an exception is thrown while trying to copy or paste clipboard contents using TextCopy.
/// If no event handlers are added, the exception is ignored.
/// </summary>
public static event Action<Exception> OnCopyPasteException;
#endif
/// <summary>
/// The color that this text field's text should display with
/// </summary>
@ -111,11 +121,11 @@ namespace MLEM.Ui.Elements {
set => this.textInput.Multiline = value;
}
#if FNA
#if FNA
/// <inheritdoc />
// we need to make sure that the enter press doesn't get consumed by our press function so that it still works in TextInput
public override bool CanBePressed => base.CanBePressed && !this.IsSelected;
#endif
#endif
/// <summary>
/// The text that displays in this text field if <see cref="Text"/> is empty
@ -144,7 +154,24 @@ namespace MLEM.Ui.Elements {
/// <param name="text">The text that the text field should contain by default</param>
/// <param name="multiline">Whether the text field should support multi-line editing</param>
public TextField(Anchor anchor, Vector2 size, Rule rule = null, GenericFont font = null, string text = null, bool multiline = false) : base(anchor, size) {
this.textInput = new TextInput(null, Vector2.Zero, 1, null, ClipboardService.SetText, ClipboardService.GetText) {
this.textInput = new TextInput(null, Vector2.Zero, 1
#if NETSTANDARD2_0_OR_GREATER || NET6_0_OR_GREATER
, null, s => {
try {
ClipboardService.SetText(s);
} catch (Exception e) {
TextField.OnCopyPasteException?.Invoke(e);
}
}, () => {
try {
return ClipboardService.GetText();
} catch (Exception e) {
TextField.OnCopyPasteException?.Invoke(e);
return null;
}
}
#endif
) {
OnTextChange = (i, s) => this.OnTextChange?.Invoke(this, s),
InputRule = (i, s) => this.InputRule.Invoke(this, s)
};

View file

@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using MLEM.Extensions;
using MLEM.Input;
using MLEM.Ui.Style;
#if FNA
using MLEM.Extensions;
#endif
namespace MLEM.Ui.Elements {
/// <summary>
@ -98,9 +100,9 @@ namespace MLEM.Ui.Elements {
public Tooltip(string text = null, Element elementToHover = null) :
base(Anchor.TopLeft, Vector2.One, Vector2.Zero) {
if (text != null) {
#pragma warning disable CS0618
#pragma warning disable CS0618
this.Paragraph = this.AddParagraph(text);
#pragma warning restore CS0618
#pragma warning restore CS0618
}
this.Init(elementToHover);
}
@ -112,9 +114,9 @@ namespace MLEM.Ui.Elements {
/// <param name="elementToHover">The element that should automatically cause the tooltip to appear and disappear when hovered and not hovered, respectively</param>
public Tooltip(Paragraph.TextCallback textCallback, Element elementToHover = null) :
base(Anchor.TopLeft, Vector2.One, Vector2.Zero) {
#pragma warning disable CS0618
#pragma warning disable CS0618
this.Paragraph = this.AddParagraph(textCallback);
#pragma warning restore CS0618
#pragma warning restore CS0618
this.Init(elementToHover);
}
@ -296,11 +298,11 @@ namespace MLEM.Ui.Elements {
foreach (var paragraph in this.Paragraphs)
this.UpdateParagraphStyle(paragraph);
#pragma warning disable CS0618
#pragma warning disable CS0618
// still set style here in case someone changed the paragraph field manually
if (this.Paragraph != null)
this.UpdateParagraphStyle(this.Paragraph);
#pragma warning restore CS0618
#pragma warning restore CS0618
}
private void UpdateParagraphStyle(Paragraph paragraph) {

View file

@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net452;netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
<RootNamespace>MLEM.Ui</RootNamespace>
<DefineConstants>$(DefineConstants);FNA</DefineConstants>
</PropertyGroup>
@ -20,10 +21,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TextCopy" Version="6.1.0" />
<PackageReference Include="TextCopy" Version="6.2.0" Condition="'$(TargetFramework)'!='net452'" />
<ProjectReference Include="..\MLEM\MLEM.FNA.csproj" />
<ProjectReference Include="..\FNA\FNA.Core.csproj">
<ProjectReference Include="..\FNA\FNA.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
</ItemGroup>

View file

@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net452;netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>
<PropertyGroup>
@ -18,7 +19,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TextCopy" Version="6.1.0" />
<PackageReference Include="TextCopy" Version="6.2.0" Condition="'$(TargetFramework)'!='net452'" />
<ProjectReference Include="..\MLEM\MLEM.csproj" />
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.0.1641">

View file

@ -1,21 +1,12 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Formatting;
using MLEM.Misc;
using MLEM.Textures;
using MLEM.Ui.Elements;
using MLEM.Ui.Style;
namespace MLEM.Ui.Parsers {
/// <summary>
/// A class for parsing Markdown strings into a set of MLEM.Ui elements with styling for each individual <see cref="ElementType"/>.
/// To parse, use <see cref="Parse"/> or <see cref="ParseInto"/>. To style the parsed output, use <see cref="Style{T}"/> before parsing.
/// A class for parsing Markdown strings into a set of MLEM.Ui elements with styling for each individual <see cref="UiParser.ElementType"/>.
/// To parse, use <see cref="UiParser.Parse"/> or <see cref="UiParser.ParseInto"/>. To style the parsed output, use <see cref="UiParser.Style{T}"/> before parsing.
/// </summary>
/// <remarks>
/// Note that this parser is rather rudimentary and doesn't deal well with very complex Markdown documents. Missing features are as follows:
@ -26,106 +17,18 @@ namespace MLEM.Ui.Parsers {
/// <item><description>Tables</description></item>
/// </list>
/// </remarks>
public class UiMarkdownParser {
private static readonly ElementType[] ElementTypes = EnumHelper.GetValues<ElementType>().ToArray();
/// <summary>
/// The base path for markdown images, which is prepended to the image link.
/// </summary>
public string ImageBasePath;
/// <summary>
/// An action that is invoked when an image fails to load while parsing.
/// This action receives the expected location of the image, as well as the <see cref="Exception"/> that occured.
/// </summary>
public Action<string, Exception> ImageExceptionHandler;
/// <summary>
/// The graphics device that should be used when loading images and other graphics-dependent content.
/// </summary>
public GraphicsDevice GraphicsDevice;
/// <summary>
/// The name of the font used for inline code as well as code blocks.
/// This only has an effect if a font with this name is added to the used <see cref="UiStyle"/>'s <see cref="UiStyle.AdditionalFonts"/>.
/// This defaults to "Monospaced" if default styling is applied in <see cref="UiMarkdownParser(bool)"/>.
/// </summary>
public string CodeFont;
private readonly Dictionary<ElementType, Action<Element>> elementStyles = new Dictionary<ElementType, Action<Element>>();
public class UiMarkdownParser : UiParser {
/// <summary>
/// Creates a new UI markdown parser and optionally initializes some default style settings.
/// </summary>
/// <param name="applyDefaultStyling">Whether default style settings should be applied.</param>
public UiMarkdownParser(bool applyDefaultStyling = true) {
if (applyDefaultStyling) {
this.CodeFont = "Monospaced";
this.Style<VerticalSpace>(ElementType.VerticalSpace, v => v.Size = new Vector2(1, 5));
for (var i = 0; i < 6; i++) {
var level = i;
this.Style<Paragraph>(UiMarkdownParser.ElementTypes[Array.IndexOf(UiMarkdownParser.ElementTypes, ElementType.Header1) + i], p => {
p.Alignment = TextAlignment.Center;
p.TextScaleMultiplier = 2 - level * 0.15F;
});
}
}
}
public UiMarkdownParser(bool applyDefaultStyling = true) : base(applyDefaultStyling) {}
/// <summary>
/// Parses the given markdown string into a set of elements (using <see cref="Parse"/>) and adds them as children to the givem <paramref name="element"/>.
/// During this process, the element stylings specified using <see cref="Style{T}"/> are also applied.
/// </summary>
/// <param name="markdown">The markdown to parse.</param>
/// <param name="element">The element to add the parsed elements to.</param>
/// <returns>The <paramref name="element"/>, for chaining.</returns>
public Element ParseInto(string markdown, Element element) {
foreach (var (_, e) in this.Parse(markdown))
element.AddChild(e);
return element;
}
/// <summary>
/// Parses the given markdown string into a set of elements and returns them along with their <see cref="ElementType"/>.
/// During this process, the element stylings specified using <see cref="Style{T}"/> are also applied.
/// </summary>
/// <param name="markdown">The markdown to parse.</param>
/// <returns>The parsed elements.</returns>
public IEnumerable<(ElementType, Element)> Parse(string markdown) {
foreach (var (t, e) in this.ParseUnstyled(markdown)) {
if (this.elementStyles.TryGetValue(t, out var style))
style.Invoke(e);
yield return (t, e);
}
}
/// <summary>
/// Specifies an action to be invoked when a new element with the given <see cref="ElementType"/> is parsed in <see cref="Parse"/> or <see cref="ParseInto"/>.
/// These actions can be used to modify the style properties of the created elements.
/// </summary>
/// <param name="types">The element types that should be styled. Can be a combined flag.</param>
/// <param name="style">The action that styles the elements with the given element type.</param>
/// <param name="add">Whether the <paramref name="style"/> function should be added to the existing style settings, or replace them.</param>
/// <typeparam name="T">The type of elements that the given <see cref="ElementType"/> flags are expected to be.</typeparam>
/// <returns>This parser, for chaining.</returns>
public UiMarkdownParser Style<T>(ElementType types, Action<T> style, bool add = false) where T : Element {
foreach (var type in UiMarkdownParser.ElementTypes) {
if (types.HasFlag(type)) {
if (add && this.elementStyles.ContainsKey(type)) {
this.elementStyles[type] += Action;
} else {
this.elementStyles[type] = Action;
}
}
}
return this;
void Action(Element e) {
style.Invoke(e as T ?? throw new ArgumentException($"Expected {typeof(T)} for style action but got {e.GetType()}"));
}
}
private IEnumerable<(ElementType, Element)> ParseUnstyled(string markdown) {
/// <inheritdoc />
protected override IEnumerable<(ElementType, Element)> ParseUnstyled(string raw) {
var inCodeBlock = false;
foreach (var line in markdown.Split('\n')) {
foreach (var line in raw.Split('\n')) {
// code blocks
if (line.Trim().StartsWith("```")) {
inCodeBlock = !inCodeBlock;
@ -139,7 +42,7 @@ namespace MLEM.Ui.Parsers {
// quotes
if (line.StartsWith(">")) {
yield return (ElementType.Blockquote, new Paragraph(Anchor.AutoLeft, 1, line.Substring(1).Trim()));
yield return (ElementType.Blockquote, new Paragraph(Anchor.AutoLeft, 1, this.ParseParagraph(line.Substring(1).Trim())));
continue;
}
@ -152,45 +55,7 @@ namespace MLEM.Ui.Parsers {
// images
var imageMatch = Regex.Match(line, @"!\[\]\(([^)]+)\)");
if (imageMatch.Success) {
if (this.GraphicsDevice == null)
throw new NullReferenceException("A markdown parser requires a GraphicsDevice for parsing images");
TextureRegion image = null;
LoadImageAsync();
yield return (ElementType.Image, new Image(Anchor.AutoLeft, new Vector2(1, -1), _ => image) {
OnDisposed = e => image?.Texture.Dispose()
});
async void LoadImageAsync() {
var loc = imageMatch.Groups[1].Value;
// only apply the base path for relative files
if (this.ImageBasePath != null && !loc.StartsWith("http") && !Path.IsPathRooted(loc))
loc = $"{this.ImageBasePath}/{loc}";
try {
Texture2D tex;
if (loc.StartsWith("http")) {
using (var client = new HttpClient()) {
using (var src = await client.GetStreamAsync(loc)) {
using (var memory = new MemoryStream()) {
// download the full stream before passing it to texture
await src.CopyToAsync(memory);
tex = Texture2D.FromStream(this.GraphicsDevice, memory);
}
}
}
} else {
using (var stream = Path.IsPathRooted(loc) ? File.OpenRead(loc) : TitleContainer.OpenStream(loc))
tex = Texture2D.FromStream(this.GraphicsDevice, stream);
}
image = new TextureRegion(tex);
} catch (Exception e) {
if (this.ImageExceptionHandler != null) {
this.ImageExceptionHandler.Invoke(loc, e);
} else {
throw new Exception($"Couldn't parse image {loc}, and no ImageExceptionHandler was set", e);
}
}
}
yield return (ElementType.Image, this.ParseImage(imageMatch.Groups[1].Value));
continue;
}
@ -198,8 +63,8 @@ namespace MLEM.Ui.Parsers {
var parsedHeader = false;
for (var h = 6; h >= 1; h--) {
if (line.StartsWith(new string('#', h))) {
var type = UiMarkdownParser.ElementTypes[Array.IndexOf(UiMarkdownParser.ElementTypes, ElementType.Header1) + h - 1];
yield return (type, new Paragraph(Anchor.AutoLeft, 1, line.Substring(h).Trim()));
var type = UiParser.ElementTypes[Array.IndexOf(UiParser.ElementTypes, ElementType.Header1) + h - 1];
yield return (type, new Paragraph(Anchor.AutoLeft, 1, this.ParseParagraph(line.Substring(h).Trim())));
parsedHeader = true;
break;
}
@ -208,7 +73,11 @@ namespace MLEM.Ui.Parsers {
continue;
// parse everything else as a paragraph (with formatting)
var par = line;
yield return (ElementType.Paragraph, new Paragraph(Anchor.AutoLeft, 1, this.ParseParagraph(line)));
}
}
private string ParseParagraph(string par) {
// replace links
par = Regex.Replace(par, @"<([^>]+)>", "<l $1>$1</l>");
par = Regex.Replace(par, @"\[([^\]]+)\]\(([^)]+)\)", "<l $2>$1</l>");
@ -220,78 +89,7 @@ namespace MLEM.Ui.Parsers {
par = Regex.Replace(par, @"~~([^~]+)~~", "<st>$1</st>");
// replace inline code with custom code font
par = Regex.Replace(par, @"`([^`]+)`", $"<f {this.CodeFont}>$1</f>");
yield return (ElementType.Paragraph, new Paragraph(Anchor.AutoLeft, 1, par));
}
}
/// <summary>
/// A flags enumeration used by <see cref="UiMarkdownParser"/> that contains the types of elements that can be parsed and returned in <see cref="Parse"/> or <see cref="UiMarkdownParser.ParseInto"/>.
/// This is a flags enumeration so that <see cref="UiMarkdownParser.Style{T}"/> can have multiple element types being styled at the same time.
/// </summary>
[Flags]
public enum ElementType {
/// <summary>
/// A blockquote.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Blockquote = 1,
/// <summary>
/// A vertical space, which is a gap between multiple markdown paragraphs.
/// This element type is a <see cref="VerticalSpace"/>.
/// </summary>
VerticalSpace = 2,
/// <summary>
/// An image.
/// This element type is an <see cref="Image"/>.
/// </summary>
Image = 4,
/// <summary>
/// A header with header level 1.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header1 = 8,
/// <summary>
/// A header with header level 2.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header2 = 16,
/// <summary>
/// A header with header level 3.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header3 = 32,
/// <summary>
/// A header with header level 4.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header4 = 64,
/// <summary>
/// A header with header level 5.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header5 = 128,
/// <summary>
/// A header with header level 6.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header6 = 256,
/// <summary>
/// A combined flag that contains <see cref="Header1"/> through <see cref="Header6"/>.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Headers = ElementType.Header1 | ElementType.Header2 | ElementType.Header3 | ElementType.Header4 | ElementType.Header5 | ElementType.Header6,
/// <summary>
/// A paragraph, which is one line of markdown text.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Paragraph = 512,
/// <summary>
/// A single line of a code block.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
CodeBlock = 1024
return par;
}
}

263
MLEM.Ui/Parsers/UiParser.cs Normal file
View file

@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.IO;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Formatting;
using MLEM.Textures;
using MLEM.Ui.Elements;
using MLEM.Ui.Style;
#if NETSTANDARD2_0_OR_GREATER || NET6_0_OR_GREATER
using System.Net.Http;
#else
using System.Net;
#endif
namespace MLEM.Ui.Parsers {
/// <summary>
/// A base class for parsing various types of formatted strings into a set of MLEM.Ui elements with styling for each individual <see cref="ElementType"/>.
/// The only parser currently implemented is <see cref="UiMarkdownParser"/>.
/// </summary>
public abstract class UiParser {
/// <summary>
/// An array containing all of the <see cref="ElementType"/> enum values.
/// </summary>
public static readonly ElementType[] ElementTypes =
#if NET6_0_OR_GREATER
Enum.GetValues<ElementType>();
#else
(ElementType[]) Enum.GetValues(typeof(ElementType));
#endif
/// <summary>
/// The base path for images, which is prepended to the image link.
/// </summary>
public string ImageBasePath;
/// <summary>
/// An action that is invoked when an image fails to load while parsing.
/// This action receives the expected location of the image, as well as the <see cref="Exception"/> that occured.
/// </summary>
public Action<string, Exception> ImageExceptionHandler;
/// <summary>
/// The graphics device that should be used when loading images and other graphics-dependent content.
/// </summary>
public GraphicsDevice GraphicsDevice;
/// <summary>
/// The name of the font used for inline code as well as code blocks.
/// This only has an effect if a font with this name is added to the used <see cref="UiStyle"/>'s <see cref="UiStyle.AdditionalFonts"/>.
/// This defaults to "Monospaced" if default styling is applied in <see cref="UiParser(bool)"/>.
/// </summary>
public string CodeFont;
private readonly Dictionary<ElementType, Action<Element>> elementStyles = new Dictionary<ElementType, Action<Element>>();
/// <summary>
/// Creates a new UI parser and optionally initializes some default style settings.
/// </summary>
/// <param name="applyDefaultStyling">Whether default style settings should be applied.</param>
protected UiParser(bool applyDefaultStyling) {
if (applyDefaultStyling) {
this.CodeFont = "Monospaced";
this.Style<VerticalSpace>(ElementType.VerticalSpace, v => v.Size = new Vector2(1, 5));
for (var i = 0; i < 6; i++) {
var level = i;
this.Style<Paragraph>(UiParser.ElementTypes[Array.IndexOf(UiParser.ElementTypes, UiParser.ElementType.Header1) + i], p => {
p.Alignment = TextAlignment.Center;
p.TextScaleMultiplier = 2 - level * 0.15F;
});
}
}
}
/// <summary>
/// Parses the given raw formatted string into a set of elements and returns them along with their <see cref="ElementType"/>.
/// This method is used by implementors to parse specific text, and it is used by <see cref="Parse"/> and <see cref="ParseInto"/>.
/// </summary>
/// <param name="raw">The raw string to parse.</param>
/// <returns>The parsed elements, without styling.</returns>
protected abstract IEnumerable<(ElementType, Element)> ParseUnstyled(string raw);
/// <summary>
/// Parses the given raw formatted string into a set of elements and returns them along with their <see cref="ElementType"/>.
/// During this process, the element stylings specified using <see cref="Style{T}"/> are also applied.
/// </summary>
/// <param name="raw">The raw string to parse.</param>
/// <returns>The parsed elements.</returns>
public IEnumerable<(ElementType, Element)> Parse(string raw) {
foreach (var (t, e) in this.ParseUnstyled(raw)) {
if (this.elementStyles.TryGetValue(t, out var style))
style.Invoke(e);
yield return (t, e);
}
}
/// <summary>
/// Parses the given raw formatted string into a set of elements (using <see cref="Parse"/>) and adds them as children to the givem <paramref name="element"/>.
/// During this process, the element stylings specified using <see cref="Style{T}"/> are also applied.
/// </summary>
/// <param name="raw">The raw string to parse.</param>
/// <param name="element">The element to add the parsed elements to.</param>
/// <returns>The <paramref name="element"/>, for chaining.</returns>
public Element ParseInto(string raw, Element element) {
foreach (var (_, e) in this.Parse(raw))
element.AddChild(e);
return element;
}
/// <summary>
/// Specifies an action to be invoked when a new element with the given <see cref="ElementType"/> is parsed in <see cref="Parse"/> or <see cref="ParseInto"/>.
/// These actions can be used to modify the style properties of the created elements.
/// </summary>
/// <param name="types">The element types that should be styled. Can be a combined flag.</param>
/// <param name="style">The action that styles the elements with the given element type.</param>
/// <param name="add">Whether the <paramref name="style"/> function should be added to the existing style settings, or replace them.</param>
/// <typeparam name="T">The type of elements that the given <see cref="ElementType"/> flags are expected to be.</typeparam>
/// <returns>This parser, for chaining.</returns>
public UiParser Style<T>(ElementType types, Action<T> style, bool add = false) where T : Element {
foreach (var type in UiParser.ElementTypes) {
if (types.HasFlag(type)) {
if (add && this.elementStyles.ContainsKey(type)) {
this.elementStyles[type] += Action;
} else {
this.elementStyles[type] = Action;
}
}
}
return this;
void Action(Element e) {
style.Invoke(e as T ?? throw new ArgumentException($"Expected {typeof(T)} for style action but got {e.GetType()}"));
}
}
/// <summary>
/// Parses the given path into a <see cref="Image"/> element by loading it from disk or downloading it from the internet.
/// Note that, for a <paramref name="path"/> that doesn't start with <c>http</c> and isn't rooted, the <see cref="ImageBasePath"/> is prepended automatically.
/// This method invokes an asynchronouns action, meaning the <see cref="Image"/>'s <see cref="Image.Texture"/> will likely not have loaded in when this method returns.
/// </summary>
/// <param name="path">The absolute, relative or web path to the image.</param>
/// <returns>The loaded image.</returns>
/// <exception cref="NullReferenceException">Thrown if <see cref="GraphicsDevice"/> is null, or if there is an <see cref="Exception"/> loading the image and <see cref="ImageExceptionHandler"/> is unset.</exception>
protected Image ParseImage(string path) {
if (this.GraphicsDevice == null)
throw new NullReferenceException("A UI parser requires a GraphicsDevice for parsing images");
TextureRegion image = null;
return new Image(Anchor.AutoLeft, new Vector2(1, -1), _ => image) {
OnAddedToUi = e => {
if (image == null)
LoadImageAsync();
},
OnRemovedFromUi = e => {
image?.Texture.Dispose();
image = null;
}
};
async void LoadImageAsync() {
// only apply the base path for relative files
if (this.ImageBasePath != null && !path.StartsWith("http") && !Path.IsPathRooted(path))
path = $"{this.ImageBasePath}/{path}";
try {
Texture2D tex;
if (path.StartsWith("http")) {
byte[] src;
#if NETSTANDARD2_0_OR_GREATER || NET6_0_OR_GREATER
using (var client = new HttpClient())
src = await client.GetByteArrayAsync(path);
#else
using (var client = new WebClient())
src = await client.DownloadDataTaskAsync(path);
#endif
using (var memory = new MemoryStream(src))
tex = Texture2D.FromStream(this.GraphicsDevice, memory);
} else {
using (var stream = Path.IsPathRooted(path) ? File.OpenRead(path) : TitleContainer.OpenStream(path))
tex = Texture2D.FromStream(this.GraphicsDevice, stream);
}
image = new TextureRegion(tex);
} catch (Exception e) {
if (this.ImageExceptionHandler != null) {
this.ImageExceptionHandler.Invoke(path, e);
} else {
throw new NullReferenceException($"Couldn't parse image {path}, and no ImageExceptionHandler was set", e);
}
}
}
}
/// <summary>
/// A flags enumeration used by <see cref="UiParser"/> that contains the types of elements that can be parsed and returned in <see cref="Parse"/> or <see cref="ParseInto"/>.
/// This is a flags enumeration so that <see cref="Style{T}"/> can have multiple element types being styled at the same time.
/// </summary>
[Flags]
public enum ElementType {
/// <summary>
/// A blockquote.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Blockquote = 1,
/// <summary>
/// A vertical space, which is a gap between multiple paragraphs.
/// This element type is a <see cref="VerticalSpace"/>.
/// </summary>
VerticalSpace = 2,
/// <summary>
/// An image.
/// This element type is an <see cref="Image"/>.
/// </summary>
Image = 4,
/// <summary>
/// A header with header level 1.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header1 = 8,
/// <summary>
/// A header with header level 2.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header2 = 16,
/// <summary>
/// A header with header level 3.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header3 = 32,
/// <summary>
/// A header with header level 4.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header4 = 64,
/// <summary>
/// A header with header level 5.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header5 = 128,
/// <summary>
/// A header with header level 6.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Header6 = 256,
/// <summary>
/// A combined flag that contains <see cref="Header1"/> through <see cref="Header6"/>.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Headers = ElementType.Header1 | ElementType.Header2 | ElementType.Header3 | ElementType.Header4 | ElementType.Header5 | ElementType.Header6,
/// <summary>
/// A paragraph, which is one line (or non-vertically spaced section) of text.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
Paragraph = 512,
/// <summary>
/// A single line of a code block.
/// This element type is a <see cref="Paragraph"/>.
/// </summary>
CodeBlock = 1024
}
}
}

View file

@ -84,7 +84,7 @@ namespace MLEM.Ui.Style {
/// <param name="obj">The object to compare with the current instance.</param>
/// <returns>true if <paramref name="obj">obj</paramref> and this instance are the same type and represent the same value; otherwise, false.</returns>
[Obsolete("StyleProp equality is ambiguous as it is not clear whether priority is taken into account. Compare Values instead.")]
#pragma warning disable CS0809
#pragma warning disable CS0809
public override bool Equals(object obj) {
return obj is StyleProp<T> other && this.Equals(other);
}
@ -95,7 +95,7 @@ namespace MLEM.Ui.Style {
public override int GetHashCode() {
return EqualityComparer<T>.Default.GetHashCode(this.Value);
}
#pragma warning restore CS0809
#pragma warning restore CS0809
/// <summary>Returns the fully qualified type name of this instance.</summary>
/// <returns>The fully qualified type name.</returns>

View file

@ -5,9 +5,9 @@ using MLEM.Font;
using MLEM.Formatting;
using MLEM.Formatting.Codes;
using MLEM.Misc;
using MLEM.Sound;
using MLEM.Textures;
using MLEM.Ui.Elements;
using SoundEffectInfo = MLEM.Sound.SoundEffectInfo;
namespace MLEM.Ui.Style {
/// <summary>
@ -216,7 +216,7 @@ namespace MLEM.Ui.Style {
/// The color that a <see cref="Paragraph"/>'s <see cref="Paragraph.Link"/> codes should have.
/// This value is passed to <see cref="LinkCode"/>.
/// </summary>
public Color? LinkColor;
public Color? LinkColor = Color.CornflowerBlue;
/// <summary>
/// A set of additional fonts that can be used for the <c>&lt;f FontName&gt;</c> formatting code
/// </summary>

View file

@ -9,6 +9,10 @@ using MLEM.Misc;
using MLEM.Ui.Elements;
using MLEM.Ui.Style;
#if NET452
using MLEM.Extensions;
#endif
namespace MLEM.Ui {
/// <summary>
/// UiControls holds and manages all of the controls for a <see cref="UiSystem"/>.
@ -157,29 +161,29 @@ namespace MLEM.Ui {
this.Input.Update();
this.ActiveRoot = this.System.GetRootElements().FirstOrDefault(root => root.CanBeActive);
// MOUSE INPUT
if (this.HandleMouse) {
var mousedNow = this.GetElementUnderPos(new Vector2(this.Input.ViewportMousePosition.X, this.Input.ViewportMousePosition.Y));
this.SetMousedElement(mousedNow);
if (this.Input.IsMouseButtonPressedAvailable(MouseButton.Left)) {
if (this.Input.IsPressedAvailable(MouseButton.Left)) {
this.IsAutoNavMode = false;
var selectedNow = mousedNow != null && mousedNow.CanBeSelected ? mousedNow : null;
this.SelectElement(this.ActiveRoot, selectedNow);
if (mousedNow != null && mousedNow.CanBePressed) {
this.System.InvokeOnElementPressed(mousedNow);
this.Input.TryConsumeMouseButtonPressed(MouseButton.Left);
this.Input.TryConsumePressed(MouseButton.Left);
}
} else if (this.Input.IsMouseButtonPressedAvailable(MouseButton.Right)) {
} else if (this.Input.IsPressedAvailable(MouseButton.Right)) {
this.IsAutoNavMode = false;
if (mousedNow != null && mousedNow.CanBePressed) {
this.System.InvokeOnElementSecondaryPressed(mousedNow);
this.Input.TryConsumeMouseButtonPressed(MouseButton.Right);
this.Input.TryConsumePressed(MouseButton.Right);
}
}
} else {
this.SetMousedElement(null);
}
// KEYBOARD INPUT
if (this.HandleKeyboard) {
if (this.KeyboardButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) {
if (this.SelectedElement?.Root != null && this.SelectedElement.CanBePressed) {
@ -192,7 +196,7 @@ namespace MLEM.Ui {
}
this.KeyboardButtons.TryConsumePressed(this.Input, this.GamepadIndex);
}
} else if (this.Input.IsKeyPressedAvailable(Keys.Tab)) {
} else if (this.Input.IsPressedAvailable(Keys.Tab)) {
this.IsAutoNavMode = true;
// tab or shift-tab to next or previous element
var backward = this.Input.IsModifierKeyDown(ModifierKey.Shift);
@ -201,12 +205,11 @@ namespace MLEM.Ui {
next = this.SelectedElement.GetTabNextElement(backward, next);
if (next != this.SelectedElement) {
this.SelectElement(this.ActiveRoot, next);
this.Input.TryConsumeKeyPressed(Keys.Tab);
this.Input.TryConsumePressed(Keys.Tab);
}
}
}
// TOUCH INPUT
if (this.HandleTouch) {
if (this.Input.GetViewportGesture(GestureType.Tap, out var tap)) {
this.IsAutoNavMode = false;
@ -234,9 +237,10 @@ namespace MLEM.Ui {
}
}
}
} else {
this.SetTouchedElement(null);
}
// GAMEPAD INPUT
if (this.HandleGamepad) {
if (this.GamepadButtons.IsPressedAvailable(this.Input, this.GamepadIndex)) {
if (this.SelectedElement?.Root != null && this.SelectedElement.CanBePressed) {

View file

@ -1,5 +1,4 @@
using System;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Ui.Elements;
namespace MLEM.Ui {

View file

@ -206,7 +206,12 @@ namespace MLEM.Ui {
/// <param name="style">The style settings that this ui should have. Use <see cref="UntexturedStyle"/> for the default, untextured style.</param>
/// <param name="inputHandler">The input handler that this ui's <see cref="UiControls"/> should use. If none is supplied, a new input handler is created for this ui.</param>
/// <param name="automaticViewport">If this value is set to true, the ui system's <see cref="Viewport"/> will be set automatically based on the <see cref="GameWindow"/>'s size. Defaults to true.</param>
public UiSystem(Game game, UiStyle style, InputHandler inputHandler = null, bool automaticViewport = true) : base(game) {
/// <param name="hasFontModifierFormatting">Whether default font modifier codes should be added to this ui system's <see cref="TextFormatter"/>, including bold, italic, strikethrough, shadow, subscript, and more.</param>
/// <param name="hasColorFormatting">Whether default color codes should be added to this ui system's <see cref="TextFormatter"/>, including all <see cref="Color"/> values and the ability to use custom colors.</param>
/// <param name="hasAnimationFormatting">Whether default animation codes should be added to this ui system's <see cref="TextFormatter"/>, namely the wobbly animation.</param>
/// <param name="hasMacroFormatting">Whether default macros should be added to this ui system's <see cref="TextFormatter"/>, including TeX's ~ non-breaking space and more.</param>
/// <param name="hasUiFormatting">Whether <see cref="UiSystem"/>-based formatting codes should be added to this ui system's <see cref="TextFormatter"/>, including <see cref="Paragraph.Link"/> codes and font switching.</param>
public UiSystem(Game game, UiStyle style, InputHandler inputHandler = null, bool automaticViewport = true, bool hasFontModifierFormatting = true, bool hasColorFormatting = true, bool hasAnimationFormatting = true, bool hasMacroFormatting = true, bool hasUiFormatting = true) : base(game) {
this.Controls = new UiControls(this, inputHandler);
this.style = style;
@ -248,13 +253,15 @@ namespace MLEM.Ui {
};
}
this.TextFormatter = new TextFormatter();
this.TextFormatter = new TextFormatter(hasFontModifierFormatting, hasColorFormatting, hasAnimationFormatting, hasMacroFormatting);
if (hasUiFormatting) {
this.TextFormatter.Codes.Add(new Regex("<l(?: ([^>]+))?>"), (f, m, r) => new LinkCode(m, r, 1 / 16F, 0.85F,
t => this.Controls.MousedElement is Paragraph.Link l1 && l1.Token == t || this.Controls.TouchedElement is Paragraph.Link l2 && l2.Token == t,
this.Style.LinkColor));
d => this.Style.LinkColor));
this.TextFormatter.Codes.Add(new Regex("<f ([^>]+)>"), (_, m, r) => new FontCode(m, r,
f => this.Style.AdditionalFonts != null && this.Style.AdditionalFonts.TryGetValue(m.Groups[1].Value, out var c) ? c : f));
}
}
/// <summary>
/// Update this ui system, querying the necessary events and updating each element's data.
@ -309,7 +316,7 @@ namespace MLEM.Ui {
var context = this.SpriteBatchContext;
context.TransformMatrix = root.Transform * context.TransformMatrix;
#pragma warning disable CS0618
#pragma warning disable CS0618
if (this.BlendState != null)
context.BlendState = this.BlendState;
if (this.SamplerState != null)
@ -318,12 +325,12 @@ namespace MLEM.Ui {
context.DepthStencilState = this.DepthStencilState;
if (this.Effect != null)
context.Effect = this.Effect;
#pragma warning restore CS0618
#pragma warning restore CS0618
batch.Begin(context);
#pragma warning disable CS0618
#pragma warning disable CS0618
root.Element.DrawTransformed(time, batch, this.DrawAlpha * root.Element.DrawAlpha, context.BlendState, context.SamplerState, context.DepthStencilState, context.Effect, context.TransformMatrix);
#pragma warning restore CS0618
#pragma warning restore CS0618
batch.End();
}
@ -345,9 +352,7 @@ namespace MLEM.Ui {
var root = new RootElement(name, element, this);
this.rootElements.Add(root);
root.Element.AndChildren(e => {
e.Root = root;
e.System = this;
root.InvokeOnElementAdded(e);
e.AddedToUi(this, root);
e.SetAreaDirty();
});
this.OnRootAdded?.Invoke(root);
@ -367,9 +372,7 @@ namespace MLEM.Ui {
this.rootElements.Remove(root);
this.Controls.SelectElement(root, null);
root.Element.AndChildren(e => {
e.Root = null;
e.System = null;
root.InvokeOnElementRemoved(e);
e.RemovedFromUi();
e.SetAreaDirty();
});
this.OnRootRemoved?.Invoke(root);
@ -561,7 +564,7 @@ namespace MLEM.Ui {
/// This property returns <see langword="true"/> if <see cref="CanSelectContent"/> is <see langword="true"/> and the <see cref="Element"/> is not hidden, or if it has been set to <see langword="true"/> manually.
/// </summary>
public bool CanBeActive {
get => this.canBeActive || (!this.Element.IsHidden && this.CanSelectContent);
get => this.canBeActive || !this.Element.IsHidden && this.CanSelectContent;
set => this.canBeActive = value;
}
@ -570,7 +573,7 @@ namespace MLEM.Ui {
/// </summary>
public event Element.GenericCallback OnElementAdded;
/// <summary>
/// Event that is invoked when a <see cref="Element"/> is removed rom this root element of any of its children.
/// Event that is invoked when a <see cref="Element"/> is removed rom this root element or any of its children.
/// </summary>
public event Element.GenericCallback OnElementRemoved;
/// <summary>

View file

@ -18,10 +18,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MLEM.Data", "MLEM.Data\MLEM
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MLEM.Templates", "MLEM.Templates\MLEM.Templates.csproj", "{C2A2CFED-C9E8-4675-BD66-EFC3DB210977}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demos.Android", "Demos.Android\Demos.Android.csproj", "{410C0262-131C-4D0E-910D-D01B4F7143E0}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{53D52C3F-67FB-4F32-A794-EAB140BBFC11}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Demos.Android", "Demos.Android\Demos.Android.csproj", "{DA234B13-AA46-4C03-8CFC-A969A2AEAEF2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -64,13 +64,13 @@ Global
{C2A2CFED-C9E8-4675-BD66-EFC3DB210977}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C2A2CFED-C9E8-4675-BD66-EFC3DB210977}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C2A2CFED-C9E8-4675-BD66-EFC3DB210977}.Release|Any CPU.Build.0 = Release|Any CPU
{410C0262-131C-4D0E-910D-D01B4F7143E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{410C0262-131C-4D0E-910D-D01B4F7143E0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{410C0262-131C-4D0E-910D-D01B4F7143E0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{410C0262-131C-4D0E-910D-D01B4F7143E0}.Release|Any CPU.Build.0 = Release|Any CPU
{53D52C3F-67FB-4F32-A794-EAB140BBFC11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{53D52C3F-67FB-4F32-A794-EAB140BBFC11}.Debug|Any CPU.Build.0 = Debug|Any CPU
{53D52C3F-67FB-4F32-A794-EAB140BBFC11}.Release|Any CPU.ActiveCfg = Release|Any CPU
{53D52C3F-67FB-4F32-A794-EAB140BBFC11}.Release|Any CPU.Build.0 = Release|Any CPU
{DA234B13-AA46-4C03-8CFC-A969A2AEAEF2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DA234B13-AA46-4C03-8CFC-A969A2AEAEF2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DA234B13-AA46-4C03-8CFC-A969A2AEAEF2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DA234B13-AA46-4C03-8CFC-A969A2AEAEF2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View file

@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
namespace MLEM.Extensions {
/// <summary>
/// A set of extensions for dealing with <see cref="char"/>
/// </summary>
[Obsolete("ToCachedString is deprecated. Consider using a more robust, custom implementation for text caching, or CodePointSource.ToString for UTF-32 caching.")]
public static class CharExtensions {
private static readonly Dictionary<char, string> Cache = new Dictionary<char, string>();
@ -14,6 +16,7 @@ namespace MLEM.Extensions {
/// </summary>
/// <param name="c">The character to turn into a string</param>
/// <returns>A string representing the character</returns>
[Obsolete("ToCachedString is deprecated. Consider using a more robust, custom implementation for text caching, or CodePointSource.ToString for UTF-32 caching.")]
public static string ToCachedString(this char c) {
if (!CharExtensions.Cache.TryGetValue(c, out var ret)) {
ret = c.ToString();

View file

@ -51,5 +51,29 @@ namespace MLEM.Extensions {
return combos;
}
#if NET452
/// <summary>Appends a value to the end of the sequence.</summary>
/// <param name="source">A sequence of values.</param>
/// <param name="element">The value to append to <paramref name="source"/>.</param>
/// <typeparam name="T">The type of the elements of <paramref name="source"/>.</typeparam>
/// <returns>A new sequence that ends with <paramref name="element"/>.</returns>
public static IEnumerable<T> Append<T>(this IEnumerable<T> source, T element) {
foreach (var src in source)
yield return src;
yield return element;
}
/// <summary>Prepends a value to the beginning of the sequence.</summary>
/// <param name="source">A sequence of values.</param>
/// <param name="element">The value to prepend to <paramref name="source"/>.</param>
/// <typeparam name="T">The type of the elements of <paramref name="source"/>.</typeparam>
/// <returns>A new sequence that begins with <paramref name="element"/>.</returns>
public static IEnumerable<T> Prepend<T>(this IEnumerable<T> source, T element) {
yield return element;
foreach (var src in source)
yield return src;
}
#endif
}
}

View file

@ -90,12 +90,12 @@ namespace MLEM.Extensions {
/// <param name="target">The target to apply</param>
public TargetContext(GraphicsDevice device, RenderTarget2D target) {
this.device = device;
#if FNA
#if FNA
// RenderTargetCount doesn't exist in FNA but we still want the optimization in MG
this.lastTargets = device.GetRenderTargets();
#else
#else
this.lastTargets = device.RenderTargetCount <= 0 ? null : device.GetRenderTargets();
#endif
#endif
device.SetRenderTarget(target);
}

View file

@ -288,7 +288,7 @@ namespace MLEM.Extensions {
return false;
}
#if FNA
#if FNA
/// <summary>
/// Gets a <see cref="Point"/> representation for this object.
/// </summary>
@ -304,7 +304,42 @@ namespace MLEM.Extensions {
public static Vector2 ToVector2(this Point point) {
return new Vector2(point.X, point.Y);
}
#endif
/// <summary>
/// Deconstruction method for <see cref="Point"/>.
/// </summary>
/// <param name="point">The point to deconstruct.</param>
/// <param name="x"></param>
/// <param name="y"></param>
public static void Deconstruct(this Point point, out int x, out int y) {
x = point.X;
y = point.Y;
}
/// <summary>
/// Deconstruction method for <see cref="Vector2"/>.
/// </summary>
/// <param name="vector">The vector to deconstruct.</param>
/// <param name="x"></param>
/// <param name="y"></param>
public static void Deconstruct(this Vector2 vector, out float x, out float y) {
x = vector.X;
y = vector.Y;
}
/// <summary>
/// Deconstruction method for <see cref="Vector3"/>.
/// </summary>
/// <param name="vector">The vector to deconstruct.</param>
/// <param name="x"></param>
/// <param name="y"></param>
/// <param name="z"></param>
public static void Deconstruct(this Vector3 vector, out float x, out float y, out float z) {
x = vector.X;
y = vector.Y;
z = vector.Z;
}
#endif
}
}

View file

@ -55,5 +55,37 @@ namespace MLEM.Extensions {
throw new IndexOutOfRangeException();
}
/// <summary>
/// Returns a random floating-point number that is greater than or equal to 0, and less than <paramref name="maxValue"/>.
/// </summary>
/// <param name="random">The random.</param>
/// <param name="maxValue">The (exclusive) maximum value.</param>
/// <returns>A single-precision floating point number that is greater than or equal to 0, and less than <paramref name="maxValue"/>.</returns>
public static float NextSingle(this Random random, float maxValue) {
return maxValue * random.NextSingle();
}
/// <summary>
/// Returns a random floating-point number that is greater than or equal to <paramref name="minValue"/>, and less than <paramref name="maxValue"/>.
/// </summary>
/// <param name="random">The random.</param>
/// <param name="minValue">The (inclusive) minimum value.</param>
/// <param name="maxValue">The (exclusive) maximum value.</param>
/// <returns>A single-precision floating point number that is greater than or equal to <paramref name="minValue"/>, and less than <paramref name="maxValue"/>.</returns>
public static float NextSingle(this Random random, float minValue, float maxValue) {
return (maxValue - minValue) * random.NextSingle() + minValue;
}
#if !NET6_0_OR_GREATER
/// <summary>
/// Returns a random floating-point number that is greater than or equal to 0, and less than 1.
/// </summary>
/// <param name="random">The random.</param>
/// <returns>A single-precision floating point number that is greater than or equal to 0, and less than 1.</returns>
public static float NextSingle(this Random random) {
return (float) random.NextDouble();
}
#endif
}
}

View file

@ -0,0 +1,99 @@
using System.Collections;
using System.Collections.Generic;
using System.Text;
namespace MLEM.Font {
/// <summary>
/// A code point source is a wrapper around a <see cref="string"/> or <see cref="StringBuilder"/> that allows retrieving UTF-32 code points at a given index using <see cref="GetCodePoint"/>. Additionally, it allows enumerating every code point in the underlying <see cref="string"/> or <see cref="StringBuilder"/>. This class also contains <see cref="ToString(int)"/>, which converts a code point into its <see cref="string"/> representation, but caches the result to avoid allocating excess memory.
/// </summary>
public readonly struct CodePointSource : IEnumerable<int> {
private static readonly Dictionary<int, string> StringCache = new Dictionary<int, string>();
private readonly string strg;
private readonly StringBuilder builder;
private char this[int index] => this.strg?[index] ?? this.builder[index];
/// <summary>
/// The length of this code point, in characters.
/// Note that this is not representative of the amount of code points in this source.
/// </summary>
public int Length => this.strg?.Length ?? this.builder.Length;
/// <summary>
/// Creates a new code point source from the given <see cref="string"/>.
/// </summary>
/// <param name="strg">The <see cref="string"/> whose code points to inspect.</param>
public CodePointSource(string strg) {
this.strg = strg;
this.builder = null;
}
/// <summary>
/// Creates a new code point source from the given <see cref="StringBuilder"/>.
/// </summary>
/// <param name="builder">The <see cref="StringBuilder"/> whose code points to inspect.</param>
public CodePointSource(StringBuilder builder) {
this.strg = null;
this.builder = builder;
}
/// <summary>
/// Returns the code point at the given <paramref name="index"/> in this code point source's underlying string, where the index is measured in characters and not code points.
/// The resulting code point will either be a single <see cref="char"/> cast to an <see cref="int"/>, at which point the returned length will be 1, or a UTF-32 <see cref="int"/> character made up of two <see cref="char"/> values, at which point the returned length will be 2.
/// </summary>
/// <param name="index">The index at which to return the code point, which is measured in characters.</param>
/// <param name="indexLowSurrogate">Whether the <paramref name="index"/> represents a low surrogate. If this is <see langword="false"/>, the <paramref name="index"/> represents a high surrogate and the low surrogate will be looked for in the following character. If this is <see langword="true"/>, the <paramref name="index"/> represents a low surrogate and the high surrogate will be looked for in the previous character.</param>
/// <returns>The code point at the given location, as well as its length.</returns>
public (int CodePoint, int Length) GetCodePoint(int index, bool indexLowSurrogate = false) {
var curr = this[index];
if (indexLowSurrogate) {
if (index > 0) {
var high = this[index - 1];
if (char.IsSurrogatePair(high, curr))
return (char.ConvertToUtf32(high, curr), 2);
}
} else {
if (index < this.Length - 1) {
var low = this[index + 1];
if (char.IsSurrogatePair(curr, low))
return (char.ConvertToUtf32(curr, low), 2);
}
}
return (curr, 1);
}
/// <summary>Returns an enumerator that iterates through the collection.</summary>
/// <returns>A <see cref="T:System.Collections.Generic.IEnumerator`1" /> that can be used to iterate through the collection.</returns>
/// <filterpriority>1</filterpriority>
public IEnumerator<int> GetEnumerator() {
var index = 0;
while (index < this.Length) {
var (codePoint, length) = this.GetCodePoint(index);
yield return codePoint;
index += length;
}
}
/// <summary>Returns an enumerator that iterates through a collection.</summary>
/// <returns>An <see cref="T:System.Collections.IEnumerator" /> object that can be used to iterate through the collection.</returns>
/// <filterpriority>2</filterpriority>
IEnumerator IEnumerable.GetEnumerator() {
return this.GetEnumerator();
}
/// <summary>
/// Converts the given UTF-32 <paramref name="codePoint"/> into a string using <see cref="char.ConvertFromUtf32"/>, but caches the result in a <see cref="Dictionary{TKey,TValue}"/> cache to avoid allocating excess memory.
/// </summary>
/// <param name="codePoint">The UTF-32 code point to convert.</param>
/// <returns>The string representation of the code point.</returns>
public static string ToString(int codePoint) {
if (!CodePointSource.StringCache.TryGetValue(codePoint, out var ret)) {
ret = char.ConvertFromUtf32(codePoint);
CodePointSource.StringCache.Add(codePoint, ret);
}
return ret;
}
}
}

View file

@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Extensions;
using MLEM.Misc;
namespace MLEM.Font {
@ -50,35 +50,36 @@ namespace MLEM.Font {
public abstract float LineHeight { get; }
/// <summary>
/// Measures the width of the given character with the default scale for use in <see cref="MeasureString(string,bool)"/>.
/// Measures the width of the given code point with the default scale for use in <see cref="MeasureString(string,bool)"/>.
/// Note that this method does not support <see cref="Nbsp"/>, <see cref="Zwsp"/> and <see cref="Emsp"/> for most generic fonts, which is why <see cref="MeasureString(string,bool)"/> should be used even for single characters.
/// </summary>
/// <param name="c">The character whose width to calculate</param>
/// <param name="codePoint">The code point whose width to calculate</param>
/// <returns>The width of the given character with the default scale</returns>
protected abstract float MeasureChar(char c);
protected abstract float MeasureCharacter(int codePoint);
/// <summary>
/// Draws the given character with the given data for use in <see cref="DrawString(Microsoft.Xna.Framework.Graphics.SpriteBatch,System.Text.StringBuilder,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Color,float,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Graphics.SpriteEffects,float)"/>.
/// Draws the given code point with the given data for use in <see cref="DrawString(Microsoft.Xna.Framework.Graphics.SpriteBatch,System.Text.StringBuilder,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Color,float,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Vector2,Microsoft.Xna.Framework.Graphics.SpriteEffects,float)"/>.
/// Note that this method is only called internally.
/// </summary>
/// <param name="batch">The sprite batch to draw with.</param>
/// <param name="cString">A string representation of the character which will be drawn.</param>
/// <param name="codePoint">The code point which will be drawn.</param>
/// <param name="character">A string representation of the character which will be drawn.</param>
/// <param name="position">The drawing location on screen.</param>
/// <param name="color">A color mask.</param>
/// <param name="rotation">A rotation of this character.</param>
/// <param name="scale">A scaling of this character.</param>
/// <param name="effects">Modificators for drawing. Can be combined.</param>
/// <param name="layerDepth">A depth of the layer of this character.</param>
protected abstract void DrawChar(SpriteBatch batch, string cString, Vector2 position, Color color, float rotation, Vector2 scale, SpriteEffects effects, float layerDepth);
protected abstract void DrawCharacter(SpriteBatch batch, int codePoint, string character, Vector2 position, Color color, float rotation, Vector2 scale, SpriteEffects effects, float layerDepth);
///<inheritdoc cref="SpriteBatch.DrawString(SpriteFont,string,Vector2,Color,float,Vector2,float,SpriteEffects,float)"/>
public void DrawString(SpriteBatch batch, string text, Vector2 position, Color color, float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth) {
this.DrawString(batch, new CharSource(text), position, color, rotation, origin, scale, effects, layerDepth);
this.DrawString(batch, new CodePointSource(text), position, color, rotation, origin, scale, effects, layerDepth);
}
///<inheritdoc cref="SpriteBatch.DrawString(SpriteFont,string,Vector2,Color,float,Vector2,float,SpriteEffects,float)"/>
public void DrawString(SpriteBatch batch, StringBuilder text, Vector2 position, Color color, float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth) {
this.DrawString(batch, new CharSource(text), position, color, rotation, origin, scale, effects, layerDepth);
this.DrawString(batch, new CodePointSource(text), position, color, rotation, origin, scale, effects, layerDepth);
}
///<inheritdoc cref="SpriteBatch.DrawString(SpriteFont,string,Vector2,Color,float,Vector2,float,SpriteEffects,float)"/>
@ -103,19 +104,19 @@ namespace MLEM.Font {
/// <summary>
/// Measures the width of the given string when drawn with this font's underlying font.
/// This method uses <see cref="MeasureChar"/> internally to calculate the size of known characters and calculates additional characters like <see cref="Nbsp"/>, <see cref="Zwsp"/> and <see cref="Emsp"/>.
/// This method uses <see cref="MeasureCharacter"/> internally to calculate the size of known characters and calculates additional characters like <see cref="Nbsp"/>, <see cref="Zwsp"/> and <see cref="Emsp"/>.
/// If the text contains newline characters (\n), the size returned will represent a rectangle that encompasses the width of the longest line and the string's full height.
/// </summary>
/// <param name="text">The text whose size to calculate</param>
/// <param name="ignoreTrailingSpaces">Whether trailing whitespace should be ignored in the returned size, causing the end of each line to be effectively trimmed</param>
/// <returns>The size of the string when drawn with this font</returns>
public Vector2 MeasureString(string text, bool ignoreTrailingSpaces = false) {
return this.MeasureString(new CharSource(text), ignoreTrailingSpaces, null);
return this.MeasureString(new CodePointSource(text), ignoreTrailingSpaces);
}
/// <inheritdoc cref="MeasureString(string,bool)"/>
public Vector2 MeasureString(StringBuilder text, bool ignoreTrailingSpaces = false) {
return this.MeasureString(new CharSource(text), ignoreTrailingSpaces, null);
return this.MeasureString(new CodePointSource(text), ignoreTrailingSpaces);
}
/// <summary>
@ -129,12 +130,12 @@ namespace MLEM.Font {
/// <param name="ellipsis">The characters to add to the end of the string if it is too long</param>
/// <returns>The truncated string, or the same string if it is shorter than the maximum width</returns>
public string TruncateString(string text, float width, float scale, bool fromBack = false, string ellipsis = "") {
return this.TruncateString(new CharSource(text), width, scale, fromBack, ellipsis, null).ToString();
return GenericFont.TruncateString(Enumerable.Repeat(new DecoratedCodePointSource(new CodePointSource(text), this, 0), 1), width, scale, fromBack, ellipsis).First().ToString();
}
/// <inheritdoc cref="TruncateString(string,float,float,bool,string)"/>
public StringBuilder TruncateString(StringBuilder text, float width, float scale, bool fromBack = false, string ellipsis = "") {
return this.TruncateString(new CharSource(text), width, scale, fromBack, ellipsis, null);
return GenericFont.TruncateString(Enumerable.Repeat(new DecoratedCodePointSource(new CodePointSource(text), this, 0), 1), width, scale, fromBack, ellipsis).First();
}
/// <summary>
@ -165,22 +166,23 @@ namespace MLEM.Font {
/// <param name="scale">The scale to use for width measurements</param>
/// <returns>The split string as an enumerable of split sections</returns>
public IEnumerable<string> SplitStringSeparate(string text, float width, float scale) {
return this.SplitStringSeparate(new CharSource(text), width, scale, null);
return GenericFont.SplitStringSeparate(Enumerable.Repeat(new DecoratedCodePointSource(new CodePointSource(text), this, 0), 1), width, scale).First();
}
/// <inheritdoc cref="SplitStringSeparate(string,float,float)"/>
public IEnumerable<string> SplitStringSeparate(StringBuilder text, float width, float scale) {
return this.SplitStringSeparate(new CharSource(text), width, scale, null);
return GenericFont.SplitStringSeparate(Enumerable.Repeat(new DecoratedCodePointSource(new CodePointSource(text), this, 0), 1), width, scale).First();
}
internal Vector2 MeasureString(CharSource text, bool ignoreTrailingSpaces, Func<int, GenericFont> fontFunction) {
private Vector2 MeasureString(CodePointSource text, bool ignoreTrailingSpaces) {
var size = Vector2.Zero;
if (text.Length <= 0)
return size;
var xOffset = 0F;
for (var i = 0; i < text.Length; i++) {
var font = fontFunction?.Invoke(i) ?? this;
switch (text[i]) {
var index = 0;
while (index < text.Length) {
var (codePoint, length) = text.GetCodePoint(index);
switch (codePoint) {
case '\n':
xOffset = 0;
size.Y += this.LineHeight;
@ -189,107 +191,39 @@ namespace MLEM.Font {
xOffset += this.LineHeight;
break;
case GenericFont.Nbsp:
xOffset += font.MeasureChar(' ');
xOffset += this.MeasureCharacter(' ');
break;
case GenericFont.Zwsp:
// don't add width for a zero-width space
break;
case ' ':
if (ignoreTrailingSpaces && GenericFont.IsTrailingSpace(text, i)) {
if (ignoreTrailingSpaces && GenericFont.IsTrailingSpace(text, index)) {
// if this is a trailing space, we can skip remaining spaces too
i = text.Length - 1;
index = text.Length - 1;
break;
}
xOffset += font.MeasureChar(' ');
xOffset += this.MeasureCharacter(' ');
break;
default:
xOffset += font.MeasureChar(text[i]);
xOffset += this.MeasureCharacter(codePoint);
break;
}
// increase x size if this line is the longest
if (xOffset > size.X)
size.X = xOffset;
index += length;
}
// include the last line's height too!
size.Y += this.LineHeight;
return size;
}
internal StringBuilder TruncateString(CharSource text, float width, float scale, bool fromBack, string ellipsis, Func<int, GenericFont> fontFunction) {
var total = new StringBuilder();
for (var i = 0; i < text.Length; i++) {
if (fromBack) {
total.Insert(0, text[text.Length - 1 - i]);
} else {
total.Append(text[i]);
}
var font = fontFunction?.Invoke(i) ?? this;
if (font.MeasureString(total + ellipsis).X * scale >= width) {
if (fromBack) {
return total.Remove(0, 1).Insert(0, ellipsis);
} else {
return total.Remove(total.Length - 1, 1).Append(ellipsis);
}
}
}
return total;
}
internal IEnumerable<string> SplitStringSeparate(CharSource text, float width, float scale, Func<int, GenericFont> fontFunction) {
var currWidth = 0F;
var lastSpaceIndex = -1;
var widthSinceLastSpace = 0F;
var curr = new StringBuilder();
for (var i = 0; i < text.Length; i++) {
var c = text[i];
if (c == '\n') {
// fake split at pre-defined new lines
curr.Append(c);
lastSpaceIndex = -1;
widthSinceLastSpace = 0;
currWidth = 0;
} else {
var font = fontFunction?.Invoke(i) ?? this;
var cWidth = font.MeasureString(c.ToCachedString()).X * scale;
if (c == ' ' || c == GenericFont.Emsp || c == GenericFont.Zwsp) {
// remember the location of this (breaking!) space
lastSpaceIndex = curr.Length;
widthSinceLastSpace = 0;
} else if (currWidth + cWidth >= width) {
// check if this line contains a space
if (lastSpaceIndex < 0) {
// if there is no last space, the word is longer than a line so we split here
yield return curr.ToString();
currWidth = 0;
curr.Clear();
} else {
// split after the last space
yield return curr.ToString().Substring(0, lastSpaceIndex + 1);
curr.Remove(0, lastSpaceIndex + 1);
// we need to restore the width accumulated since the last space for the new line
currWidth = widthSinceLastSpace;
}
widthSinceLastSpace = 0;
lastSpaceIndex = -1;
}
// add current character
currWidth += cWidth;
widthSinceLastSpace += cWidth;
curr.Append(c);
}
}
if (curr.Length > 0)
yield return curr.ToString();
}
private void DrawString(SpriteBatch batch, CharSource text, Vector2 position, Color color, float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth) {
private void DrawString(SpriteBatch batch, CodePointSource text, Vector2 position, Color color, float rotation, Vector2 origin, Vector2 scale, SpriteEffects effects, float layerDepth) {
var (flipX, flipY) = (0F, 0F);
var flippedV = (effects & SpriteEffects.FlipVertically) != 0;
var flippedH = (effects & SpriteEffects.FlipHorizontally) != 0;
if (flippedV || flippedH) {
var size = this.MeasureString(text, false, null);
var size = this.MeasureString(text, false);
if (flippedH) {
origin.X *= -1;
flipX = -size.X;
@ -318,53 +252,170 @@ namespace MLEM.Font {
}
var offset = Vector2.Zero;
for (var i = 0; i < text.Length; i++) {
var c = text[i];
if (c == '\n') {
var index = 0;
while (index < text.Length) {
var (codePoint, length) = text.GetCodePoint(index);
if (codePoint == '\n') {
offset.X = 0;
offset.Y += this.LineHeight;
continue;
}
var cString = c.ToCachedString();
var cSize = this.MeasureString(cString);
} else {
var character = CodePointSource.ToString(codePoint);
var charSize = this.MeasureString(character);
var charPos = offset;
if (flippedH)
charPos.X += cSize.X;
charPos.X += charSize.X;
if (flippedV)
charPos.Y += cSize.Y - this.LineHeight;
charPos.Y += charSize.Y - this.LineHeight;
Vector2.Transform(ref charPos, ref trans, out charPos);
this.DrawChar(batch, cString, charPos, color, rotation, scale, effects, layerDepth);
offset.X += cSize.X;
this.DrawCharacter(batch, codePoint, character, charPos, color, rotation, scale, effects, layerDepth);
offset.X += charSize.X;
}
index += length;
}
}
private static bool IsTrailingSpace(CharSource s, int index) {
for (var i = index + 1; i < s.Length; i++) {
if (s[i] != ' ')
internal static IEnumerable<IEnumerable<string>> SplitStringSeparate(IEnumerable<DecoratedCodePointSource> text, float maxWidth, float scale) {
var currWidth = 0F;
var lastSpacePart = -1;
var lastSpaceIndex = -1;
var widthSinceLastSpace = 0F;
var curr = new StringBuilder();
var fullSplit = new List<List<string>>();
foreach (var part in text) {
var partSplit = new List<string>();
AddWidth(partSplit, part.ExtraWidth * scale, true);
var index = 0;
while (index < part.Source.Length) {
var (codePoint, length) = part.Source.GetCodePoint(index);
if (codePoint == '\n') {
// fake split at pre-defined new lines
curr.Append('\n');
lastSpacePart = -1;
lastSpaceIndex = -1;
widthSinceLastSpace = 0;
currWidth = 0;
} else {
var character = CodePointSource.ToString(codePoint);
var charWidth = part.Font.MeasureString(character).X * scale;
if (codePoint == ' ' || codePoint == GenericFont.Emsp || codePoint == GenericFont.Zwsp) {
// remember the location of this (breaking!) space
lastSpacePart = fullSplit.Count;
lastSpaceIndex = curr.Length;
widthSinceLastSpace = 0;
// we never want to insert a line break before a space!
AddWidth(partSplit, charWidth, false);
} else {
AddWidth(partSplit, charWidth, true);
}
curr.Append(character);
}
index += length;
}
if (curr.Length > 0) {
partSplit.Add(curr.ToString());
curr.Clear();
}
fullSplit.Add(partSplit);
}
return fullSplit;
void AddWidth(ICollection<string> partSplit, float width, bool canBreakHere) {
if (canBreakHere && currWidth + width >= maxWidth) {
// check if this line contains a space
if (lastSpaceIndex < 0) {
// if there is no last space, the word is longer than a line so we split here
partSplit.Add(curr.ToString());
curr.Clear();
currWidth = 0;
} else {
if (lastSpacePart < fullSplit.Count) {
// the last space exists, but isn't a part of curr, so we have to backtrack and split the previous token
var prevPart = fullSplit[lastSpacePart];
var prevCurr = prevPart[prevPart.Count - 1];
prevPart[prevPart.Count - 1] = prevCurr.Substring(0, lastSpaceIndex + 1);
prevPart.Add(prevCurr.Substring(lastSpaceIndex + 1));
} else {
// split after the last space
partSplit.Add(curr.ToString().Substring(0, lastSpaceIndex + 1));
curr.Remove(0, lastSpaceIndex + 1);
}
// we need to restore the width accumulated since the last space for the new line
currWidth = widthSinceLastSpace;
}
widthSinceLastSpace = 0;
lastSpacePart = -1;
lastSpaceIndex = -1;
}
currWidth += width;
widthSinceLastSpace += width;
}
}
internal static IEnumerable<StringBuilder> TruncateString(IEnumerable<DecoratedCodePointSource> text, float maxWidth, float scale, bool fromBack, string ellipsis) {
var total = new StringBuilder();
var extraWidth = 0F;
var endReached = false;
foreach (var part in fromBack ? text.Reverse() : text) {
var curr = new StringBuilder();
// if we reached the end previously, all the other parts should just be empty
if (!endReached) {
extraWidth += part.ExtraWidth * scale;
var index = 0;
while (index < part.Source.Length) {
var innerIndex = fromBack ? part.Source.Length - 1 - index : index;
var (codePoint, length) = part.Source.GetCodePoint(innerIndex, fromBack);
var character = CodePointSource.ToString(codePoint);
if (fromBack) {
curr.Insert(0, character);
total.Insert(0, character);
} else {
curr.Append(character);
total.Append(character);
}
if (part.Font.MeasureString(new CodePointSource(total + ellipsis), false).X * scale + extraWidth >= maxWidth) {
if (fromBack) {
curr.Remove(0, length).Insert(0, ellipsis);
total.Remove(0, length).Insert(0, ellipsis);
} else {
curr.Remove(curr.Length - length, length).Append(ellipsis);
total.Remove(total.Length - length, length).Append(ellipsis);
}
endReached = true;
break;
}
index += length;
}
}
yield return curr;
}
}
private static bool IsTrailingSpace(CodePointSource s, int index) {
while (index < s.Length) {
var (codePoint, length) = s.GetCodePoint(index);
if (codePoint != ' ')
return false;
index += length;
}
return true;
}
internal readonly struct CharSource {
internal readonly struct DecoratedCodePointSource {
private readonly string strg;
private readonly StringBuilder builder;
public readonly CodePointSource Source;
public readonly GenericFont Font;
public readonly float ExtraWidth;
public int Length => this.strg?.Length ?? this.builder.Length;
public char this[int index] => this.strg?[index] ?? this.builder[index];
public CharSource(string strg) {
this.strg = strg;
this.builder = null;
}
public CharSource(StringBuilder builder) {
this.strg = null;
this.builder = builder;
public DecoratedCodePointSource(CodePointSource source, GenericFont font, float extraWidth) {
this.Source = source;
this.Font = font;
this.ExtraWidth = extraWidth;
}
}

View file

@ -1,7 +1,9 @@
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Extensions;
#if !FNA
using System.Linq;
#endif
namespace MLEM.Font {
/// <inheritdoc/>
@ -32,20 +34,20 @@ namespace MLEM.Font {
}
/// <inheritdoc />
protected override float MeasureChar(char c) {
return this.Font.MeasureString(c.ToCachedString()).X;
protected override float MeasureCharacter(int codePoint) {
return this.Font.MeasureString(CodePointSource.ToString(codePoint)).X;
}
/// <inheritdoc />
protected override void DrawChar(SpriteBatch batch, string cString, Vector2 position, Color color, float rotation, Vector2 scale, SpriteEffects effects, float layerDepth) {
batch.DrawString(this.Font, cString, position, color, rotation, Vector2.Zero, scale, effects, layerDepth);
protected override void DrawCharacter(SpriteBatch batch, int codePoint, string character, Vector2 position, Color color, float rotation, Vector2 scale, SpriteEffects effects, float layerDepth) {
batch.DrawString(this.Font, character, position, color, rotation, Vector2.Zero, scale, effects, layerDepth);
}
private static SpriteFont SetDefaults(SpriteFont font) {
#if FNA
#if FNA
// none of the copying is available with FNA
return font;
#else
#else
// we copy the font here to set the default character to a space
return new SpriteFont(
font.Texture,
@ -56,7 +58,7 @@ namespace MLEM.Font {
font.Spacing,
font.Glyphs.Select(g => new Vector3(g.LeftSideBearing, g.Width, g.RightSideBearing)).ToList(),
' ');
#endif
#endif
}
}

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using Microsoft.Xna.Framework;
@ -68,6 +69,11 @@ namespace MLEM.Formatting.Codes {
return null;
}
/// <inheritdoc cref="Token.GetSelfWidth"/>
public virtual float GetSelfWidth(GenericFont font) {
return 0;
}
/// <summary>
/// Update this formatting code's animations etc.
/// </summary>
@ -80,12 +86,13 @@ namespace MLEM.Formatting.Codes {
/// </summary>
/// <param name="font">The font that is used</param>
/// <returns>The replacement string for this formatting code</returns>
[Obsolete("This method is deprecated. Use GetSelfWidth to add additional width to this code and DrawSelf or DrawCharacter to draw additional items.")]
public virtual string GetReplacementString(GenericFont font) {
return string.Empty;
}
/// <inheritdoc cref="Formatting.Token.DrawCharacter"/>
public virtual bool DrawCharacter(GameTime time, SpriteBatch batch, char c, string cString, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
public virtual bool DrawCharacter(GameTime time, SpriteBatch batch, int codePoint, string character, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
return false;
}

View file

@ -26,8 +26,8 @@ namespace MLEM.Formatting.Codes {
}
/// <inheritdoc />
public override string GetReplacementString(GenericFont font) {
return GenericFont.Emsp.ToCachedString();
public override float GetSelfWidth(GenericFont font) {
return font.LineHeight;
}
/// <inheritdoc />
@ -41,12 +41,6 @@ namespace MLEM.Formatting.Codes {
batch.Draw(this.image.CurrentRegion, new RectangleF(pos, new Vector2(font.LineHeight * scale)), actualColor);
}
/// <inheritdoc />
public override bool DrawCharacter(GameTime time, SpriteBatch batch, char c, string cString, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
// we don't want to draw the first (space) character (in case it is set to a missing character in FNA)
return indexInToken == 0;
}
}
/// <summary>

View file

@ -9,14 +9,19 @@ namespace MLEM.Formatting.Codes {
public class LinkCode : UnderlineCode {
private readonly Func<Token, bool> isSelected;
private readonly Color? color;
private readonly Func<Color, Color?> color;
/// <inheritdoc />
public LinkCode(Match match, Regex regex, float thickness, float yOffset, Func<Token, bool> isSelected, Color? color = null) : base(match, regex, thickness, yOffset) {
public LinkCode(Match match, Regex regex, float thickness, float yOffset, Func<Token, bool> isSelected, Func<Color, Color?> color) :
base(match, regex, thickness, yOffset) {
this.isSelected = isSelected;
this.color = color;
}
/// <inheritdoc />
public LinkCode(Match match, Regex regex, float thickness, float yOffset, Func<Token, bool> isSelected, Color? color = null) :
this(match, regex, thickness, yOffset, isSelected, d => color) {}
/// <summary>
/// Returns true if this link formatting code is currently selected or hovered over, based on the selection function.
/// </summary>
@ -31,13 +36,13 @@ namespace MLEM.Formatting.Codes {
/// <inheritdoc />
public override Color? GetColor(Color defaultPick) {
return this.color;
return this.color.Invoke(defaultPick);
}
/// <inheritdoc />
public override bool DrawCharacter(GameTime time, SpriteBatch batch, char c, string cString, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
public override bool DrawCharacter(GameTime time, SpriteBatch batch, int codePoint, string character, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
// since we inherit from UnderlineCode, we can just call base if selected
return this.IsSelected() && base.DrawCharacter(time, batch, c, cString, token, indexInToken, ref pos, font, ref color, ref scale, depth);
return this.IsSelected() && base.DrawCharacter(time, batch, codePoint, character, token, indexInToken, ref pos, font, ref color, ref scale, depth);
}
}

View file

@ -18,8 +18,8 @@ namespace MLEM.Formatting.Codes {
}
/// <inheritdoc />
public override bool DrawCharacter(GameTime time, SpriteBatch batch, char c, string cString, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
font.DrawString(batch, cString, pos + this.offset * scale, this.color.CopyAlpha(color), 0, Vector2.Zero, scale, SpriteEffects.None, depth);
public override bool DrawCharacter(GameTime time, SpriteBatch batch, int codePoint, string character, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
font.DrawString(batch, character, pos + this.offset * scale, this.color.CopyAlpha(color), 0, Vector2.Zero, scale, SpriteEffects.None, depth);
// we return false since we still want regular drawing to occur
return false;
}

View file

@ -0,0 +1,24 @@
using System.Text.RegularExpressions;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using MLEM.Font;
namespace MLEM.Formatting.Codes {
/// <inheritdoc />
public class SubSupCode : Code {
private readonly float offset;
/// <inheritdoc />
public SubSupCode(Match match, Regex regex, float offset) : base(match, regex) {
this.offset = offset;
}
/// <inheritdoc />
public override bool DrawCharacter(GameTime time, SpriteBatch batch, int codePoint, string character, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
pos.Y += this.offset * font.LineHeight * scale;
return false;
}
}
}

View file

@ -19,11 +19,11 @@ namespace MLEM.Formatting.Codes {
}
/// <inheritdoc />
public override bool DrawCharacter(GameTime time, SpriteBatch batch, char c, string cString, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
public override bool DrawCharacter(GameTime time, SpriteBatch batch, int codePoint, string character, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
// don't underline spaces at the end of lines
if (c == ' ' && token.DisplayString.Length > indexInToken + 1 && token.DisplayString[indexInToken + 1] == '\n')
if (codePoint == ' ' && token.DisplayString.Length > indexInToken + 1 && token.DisplayString[indexInToken + 1] == '\n')
return false;
var size = font.MeasureString(cString) * scale;
var size = font.MeasureString(character) * scale;
var t = size.Y * this.thickness;
batch.Draw(batch.GetBlankTexture(), new RectangleF(pos.X, pos.Y + this.yOffset * size.Y - t, size.X, t), color);
return false;

View file

@ -28,7 +28,7 @@ namespace MLEM.Formatting.Codes {
}
/// <inheritdoc />
public override bool DrawCharacter(GameTime time, SpriteBatch batch, char c, string cString, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
public override bool DrawCharacter(GameTime time, SpriteBatch batch, int codePoint, string character, Token token, int indexInToken, ref Vector2 pos, GenericFont font, ref Color color, ref float scale, float depth) {
var offset = new Vector2(0, (float) Math.Sin(token.Index + indexInToken + this.TimeIntoAnimation.TotalSeconds * this.modifier) * font.LineHeight * this.heightModifier * scale);
pos += offset;
// we return false since we still want regular drawing to occur, we just changed the position

View file

@ -29,10 +29,15 @@ namespace MLEM.Formatting {
public readonly Dictionary<Regex, Macro> Macros = new Dictionary<Regex, Macro>();
/// <summary>
/// Creates a new text formatter with a set of default formatting codes.
/// Creates a new text formatter with an optional set of default formatting codes.
/// </summary>
public TextFormatter() {
// font codes
/// <param name="hasFontModifiers">Whether default font modifier codes should be added, including bold, italic, strikethrough, shadow, subscript, and more.</param>
/// <param name="hasColors">Whether default color codes should be added, including all <see cref="Color"/> values and the ability to use custom colors.</param>
/// <param name="hasAnimations">Whether default animation codes should be added, namely the wobbly animation.</param>
/// <param name="hasMacros">Whether default macros should be added, including TeX's ~ non-breaking space and more.</param>
public TextFormatter(bool hasFontModifiers = true, bool hasColors = true, bool hasAnimations = true, bool hasMacros = true) {
// general font modifier codes
if (hasFontModifiers) {
this.Codes.Add(new Regex("<b>"), (f, m, r) => new FontCode(m, r, fnt => fnt.Bold));
this.Codes.Add(new Regex("<i>"), (f, m, r) => new FontCode(m, r, fnt => fnt.Italic));
this.Codes.Add(new Regex(@"<s(?: #([0-9\w]{6,8}) (([+-.0-9]*)))?>"), (f, m, r) => new ShadowCode(m, r,
@ -40,8 +45,14 @@ namespace MLEM.Formatting {
new Vector2(float.TryParse(m.Groups[2].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var offset) ? offset : 2)));
this.Codes.Add(new Regex("<u>"), (f, m, r) => new UnderlineCode(m, r, 1 / 16F, 0.85F));
this.Codes.Add(new Regex("<st>"), (f, m, r) => new UnderlineCode(m, r, 1 / 16F, 0.55F));
this.Codes.Add(new Regex(@"<sub(?: ([+-.0-9]+))?>"), (f, m, r) => new SubSupCode(m, r,
float.TryParse(m.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var off) ? off : 0.15F));
this.Codes.Add(new Regex(@"<sup(?: ([+-.0-9]+))?>"), (f, m, r) => new SubSupCode(m, r,
float.TryParse(m.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var off) ? -off : -0.25F));
}
// color codes
if (hasColors) {
foreach (var c in typeof(Color).GetProperties()) {
if (c.GetGetMethod().IsStatic) {
var value = (Color) c.GetValue(null);
@ -49,18 +60,23 @@ namespace MLEM.Formatting {
}
}
this.Codes.Add(new Regex(@"<c #([0-9\w]{6,8})>"), (f, m, r) => new ColorCode(m, r, ColorHelper.FromHexString(m.Groups[1].Value)));
}
// animation codes
if (hasAnimations) {
this.Codes.Add(new Regex(@"<a wobbly(?: ([+-.0-9]*) ([+-.0-9]*))?>"), (f, m, r) => new WobblyCode(m, r,
float.TryParse(m.Groups[1].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var mod) ? mod : 5,
float.TryParse(m.Groups[2].Value, NumberStyles.Number, CultureInfo.InvariantCulture, out var heightMod) ? heightMod : 1 / 8F));
}
// control codes
this.Codes.Add(new Regex(@"</(\w+)>"), (f, m, r) => new SimpleEndCode(m, r, m.Groups[1].Value));
// macros
this.Macros.Add(new Regex("~"), (f, m, r) => GenericFont.Nbsp.ToCachedString());
this.Macros.Add(new Regex("<n>"), (f, m, r) => '\n'.ToCachedString());
if (hasMacros) {
this.Macros.Add(new Regex("~"), (f, m, r) => GenericFont.Nbsp.ToString());
this.Macros.Add(new Regex("<n>"), (f, m, r) => '\n'.ToString());
}
}
/// <summary>
@ -68,8 +84,8 @@ namespace MLEM.Formatting {
/// </summary>
/// <param name="font">The font to use for tokenization. Note that this font needs to be the same that will later be used for splitting, measuring and/or drawing.</param>
/// <param name="s">The string to tokenize</param>
/// <param name="alignment">The text alignment that should be used. Note that this alignment needs to be the same that will later be used for splitting, measuring and/or drawing.</param>
/// <returns></returns>
/// <param name="alignment">The text alignment that should be used. This alignment can later be changed using <see cref="TokenizedString.Realign"/>.</param>
/// <returns>The tokenized string.</returns>
public TokenizedString Tokenize(GenericFont font, string s, TextAlignment alignment = TextAlignment.Left) {
// resolve macros
s = this.ResolveMacros(s);
@ -142,8 +158,11 @@ namespace MLEM.Formatting {
}
private static string StripFormatting(GenericFont font, string s, IEnumerable<Code> codes) {
foreach (var code in codes)
foreach (var code in codes) {
#pragma warning disable CS0618
s = code.Regex.Replace(s, code.GetReplacementString(font));
#pragma warning restore CS0618
}
return s;
}

View file

@ -80,6 +80,19 @@ namespace MLEM.Formatting {
return defaultPick;
}
/// <summary>
/// Returns the width of the token itself, including all of the <see cref="Code"/> instances that this token contains.
/// Note that this method does not return the width of this token's <see cref="DisplayString"/>, but only the width that the codes themselves take up.
/// </summary>
/// <param name="font">The font to use for calculating the width.</param>
/// <returns>The width of this token itself.</returns>
public float GetSelfWidth(GenericFont font) {
var ret = 0F;
foreach (var code in this.AppliedCodes)
ret += code.GetSelfWidth(font);
return ret;
}
/// <summary>
/// Draws the token itself, including all of the <see cref="Code"/> instances that this token contains.
/// Note that, to draw the token's actual string, <see cref="DrawCharacter"/> is used.
@ -97,31 +110,30 @@ namespace MLEM.Formatting {
}
/// <summary>
/// Draws a given character using this token's formatting options.
/// Draws a given code point using this token's formatting options.
/// </summary>
/// <param name="time">The time</param>
/// <param name="batch">The sprite batch to use</param>
/// <param name="c">The character to draw</param>
/// <param name="cString">A single-character string that contains the character to draw</param>
/// <param name="codePoint">The code point of the character to draw</param>
/// <param name="character">The string representation of the character to draw</param>
/// <param name="indexInToken">The index within this token that the character is at</param>
/// <param name="pos">The position to draw the token at</param>
/// <param name="font">The font to use to draw</param>
/// <param name="color">The color to draw with</param>
/// <param name="scale">The scale to draw at</param>
/// <param name="depth">The depth to draw at</param>
public void DrawCharacter(GameTime time, SpriteBatch batch, char c, string cString, int indexInToken, Vector2 pos, GenericFont font, Color color, float scale, float depth) {
public void DrawCharacter(GameTime time, SpriteBatch batch, int codePoint, string character, int indexInToken, Vector2 pos, GenericFont font, Color color, float scale, float depth) {
foreach (var code in this.AppliedCodes) {
if (code.DrawCharacter(time, batch, c, cString, this, indexInToken, ref pos, font, ref color, ref scale, depth))
if (code.DrawCharacter(time, batch, codePoint, character, this, indexInToken, ref pos, font, ref color, ref scale, depth))
return;
}
// if no code drew, we have to do it ourselves
font.DrawString(batch, cString, pos, color, 0, Vector2.Zero, scale, SpriteEffects.None, depth);
font.DrawString(batch, character, pos, color, 0, Vector2.Zero, scale, SpriteEffects.None, depth);
}
/// <summary>
/// Gets a list of rectangles that encompass this token's area.
/// Note that more than one rectangle is only returned if the string has been split.
/// This can be used to invoke events when the mouse is hovered over the token, for example.
/// </summary>
/// <param name="stringPos">The position that the string is drawn at</param>

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
@ -8,7 +9,6 @@ using MLEM.Extensions;
using MLEM.Font;
using MLEM.Formatting.Codes;
using MLEM.Misc;
using static MLEM.Font.GenericFont;
namespace MLEM.Formatting {
/// <summary>
@ -40,6 +40,7 @@ namespace MLEM.Formatting {
public readonly Code[] AllCodes;
private string modifiedString;
private float initialInnerOffset;
private RectangleF area;
internal TokenizedString(GenericFont font, TextAlignment alignment, string rawString, string strg, Token[] tokens) {
this.RawString = rawString;
@ -52,7 +53,7 @@ namespace MLEM.Formatting {
foreach (var code in this.AllCodes)
code.Tokens = new ReadOnlyCollection<Token>(this.Tokens.Where(t => t.AppliedCodes.Contains(code)).ToList());
this.RecalculateTokenData(font, alignment);
this.Realign(font, alignment);
}
/// <summary>
@ -64,9 +65,16 @@ namespace MLEM.Formatting {
/// <param name="scale">The scale to use for width measurements</param>
/// <param name="alignment">The text alignment that should be used for width calculations</param>
public void Split(GenericFont font, float width, float scale, TextAlignment alignment = TextAlignment.Left) {
// a split string has the same character count as the input string but with newline characters added
this.modifiedString = string.Join("\n", font.SplitStringSeparate(new CharSource(this.String), width, scale, i => this.GetFontForIndex(font, i)));
this.StoreModifiedSubstrings(font, alignment);
var index = 0;
var modified = new StringBuilder();
foreach (var part in GenericFont.SplitStringSeparate(this.AsDecoratedSources(font), width, scale)) {
var joined = string.Join("\n", part);
this.Tokens[index].ModifiedSubstring = joined;
modified.Append(joined);
index++;
}
this.modifiedString = modified.ToString();
this.Realign(font, alignment);
}
/// <summary>
@ -80,13 +88,77 @@ namespace MLEM.Formatting {
/// <param name="ellipsis">The characters to add to the end of the string if it is too long</param>
/// <param name="alignment">The text alignment that should be used for width calculations</param>
public void Truncate(GenericFont font, float width, float scale, string ellipsis = "", TextAlignment alignment = TextAlignment.Left) {
this.modifiedString = font.TruncateString(new CharSource(this.String), width, scale, false, ellipsis, i => this.GetFontForIndex(font, i)).ToString();
this.StoreModifiedSubstrings(font, alignment);
var index = 0;
var modified = new StringBuilder();
foreach (var part in GenericFont.TruncateString(this.AsDecoratedSources(font), width, scale, false, ellipsis)) {
this.Tokens[index].ModifiedSubstring = part.ToString();
modified.Append(part);
index++;
}
this.modifiedString = modified.ToString();
this.Realign(font, alignment);
}
/// <summary>
/// Realigns this tokenized string using the given <see cref="TextAlignment"/>.
/// If the <paramref name="alignment"/> is <see cref="TextAlignment.Right"/>, trailing space characters (but not <see cref="GenericFont.Nbsp"/>) will be removed.
/// </summary>
/// <param name="font">The font to use for width calculations.</param>
/// <param name="alignment">The text alignment that should be used for width calculations.</param>
public void Realign(GenericFont font, TextAlignment alignment) {
// split display strings
foreach (var token in this.Tokens)
token.SplitDisplayString = token.DisplayString.Split('\n');
// token areas and inner offsets
this.area = RectangleF.Empty;
this.initialInnerOffset = this.GetInnerOffsetX(font, 0, 0, alignment);
var innerOffset = new Vector2(this.initialInnerOffset, 0);
for (var t = 0; t < this.Tokens.Length; t++) {
var token = this.Tokens[t];
var tokenFont = token.GetFont(font);
token.InnerOffsets = new float[token.SplitDisplayString.Length - 1];
var tokenArea = new List<RectangleF>();
var selfRect = new RectangleF(innerOffset, new Vector2(token.GetSelfWidth(tokenFont), tokenFont.LineHeight));
if (!selfRect.IsEmpty) {
tokenArea.Add(selfRect);
this.area = RectangleF.Union(this.area, selfRect);
innerOffset.X += selfRect.Width;
}
for (var l = 0; l < token.SplitDisplayString.Length; l++) {
var size = tokenFont.MeasureString(token.SplitDisplayString[l], !this.EndsLater(t, l));
var rect = new RectangleF(innerOffset, size);
if (!rect.IsEmpty) {
tokenArea.Add(rect);
this.area = RectangleF.Union(this.area, rect);
}
if (l < token.SplitDisplayString.Length - 1) {
innerOffset.X = token.InnerOffsets[l] = this.GetInnerOffsetX(font, t, l + 1, alignment);
innerOffset.Y += tokenFont.LineHeight;
} else {
innerOffset.X += size.X;
}
}
token.Area = tokenArea.ToArray();
}
}
/// <inheritdoc cref="GenericFont.MeasureString(string,bool)"/>
[Obsolete("Measure is deprecated. Use GetArea, which returns the string's total size measurement, instead.")]
public Vector2 Measure(GenericFont font) {
return font.MeasureString(new CharSource(this.DisplayString), false, i => this.GetFontForIndex(font, i));
return this.GetArea(Vector2.Zero, 1).Size;
}
/// <summary>
/// Measures the area that this entire tokenized string and all of its <see cref="Tokens"/> take up and returns it as a <see cref="RectangleF"/>.
/// </summary>
/// <param name="stringPos">The position that this string is being rendered at, which will offset the resulting <see cref="RectangleF"/>.</param>
/// <param name="scale">The scale that this string is being rendered with, which will scale the resulting <see cref="RectangleF"/>.</param>
/// <returns>The area that this tokenized string takes up.</returns>
public RectangleF GetArea(Vector2 stringPos, float scale) {
return new RectangleF(stringPos + this.area.Location * scale, this.area.Size * scale);
}
/// <summary>
@ -124,115 +196,49 @@ namespace MLEM.Formatting {
var drawFont = token.GetFont(font);
var drawColor = token.GetColor(color);
token.DrawSelf(time, batch, pos + innerOffset, drawFont, drawColor, scale, depth);
innerOffset.X += token.GetSelfWidth(drawFont) * scale;
var indexInToken = 0;
for (var l = 0; l < token.SplitDisplayString.Length; l++) {
foreach (var c in token.SplitDisplayString[l]) {
var cString = c.ToCachedString();
var charIndex = 0;
var line = new CodePointSource(token.SplitDisplayString[l]);
while (charIndex < line.Length) {
var (codePoint, length) = line.GetCodePoint(charIndex);
var character = CodePointSource.ToString(codePoint);
if (indexInToken == 0)
token.DrawSelf(time, batch, pos + innerOffset, drawFont, color, scale, depth);
token.DrawCharacter(time, batch, c, cString, indexInToken, pos + innerOffset, drawFont, drawColor, scale, depth);
token.DrawCharacter(time, batch, codePoint, character, indexInToken, pos + innerOffset, drawFont, drawColor, scale, depth);
innerOffset.X += drawFont.MeasureString(cString).X * scale;
innerOffset.X += drawFont.MeasureString(character).X * scale;
charIndex += length;
indexInToken++;
}
// only split at a new line, not between tokens!
if (l < token.SplitDisplayString.Length - 1) {
innerOffset.X = token.InnerOffsets[l] * scale;
innerOffset.Y += font.LineHeight * scale;
innerOffset.Y += drawFont.LineHeight * scale;
}
}
}
}
private void StoreModifiedSubstrings(GenericFont font, TextAlignment alignment) {
if (this.Tokens.Length == 1) {
// skip substring logic for unformatted text
this.Tokens[0].ModifiedSubstring = this.modifiedString;
} else {
// this is basically a substring function that ignores added newlines for indexing
var index = 0;
var currToken = 0;
var splitIndex = 0;
var ret = new StringBuilder();
while (splitIndex < this.modifiedString.Length && currToken < this.Tokens.Length) {
var token = this.Tokens[currToken];
if (token.Substring.Length > 0) {
ret.Append(this.modifiedString[splitIndex]);
// if the current char is not an added newline, we simulate length increase
if (this.modifiedString[splitIndex] != '\n' || this.String[index] == '\n')
index++;
splitIndex++;
}
// move on to the next token if we reached its end
if (index >= token.Index + token.Substring.Length) {
token.ModifiedSubstring = ret.ToString();
ret.Clear();
currToken++;
}
}
// set additional token contents beyond our string in case we truncated
if (ret.Length > 0)
this.Tokens[currToken++].ModifiedSubstring = ret.ToString();
while (currToken < this.Tokens.Length)
this.Tokens[currToken++].ModifiedSubstring = string.Empty;
}
this.RecalculateTokenData(font, alignment);
}
private void RecalculateTokenData(GenericFont font, TextAlignment alignment) {
// split display strings
foreach (var token in this.Tokens)
token.SplitDisplayString = token.DisplayString.Split('\n');
// token areas and inner offsets
this.initialInnerOffset = this.GetInnerOffsetX(font, 0, 0, alignment);
var innerOffset = new Vector2(this.initialInnerOffset, 0);
for (var t = 0; t < this.Tokens.Length; t++) {
var token = this.Tokens[t];
var tokenFont = token.GetFont(font);
token.InnerOffsets = new float[token.SplitDisplayString.Length - 1];
var area = new List<RectangleF>();
for (var l = 0; l < token.SplitDisplayString.Length; l++) {
var size = tokenFont.MeasureString(token.SplitDisplayString[l]);
var rect = new RectangleF(innerOffset, size);
if (!rect.IsEmpty)
area.Add(rect);
if (l < token.SplitDisplayString.Length - 1) {
innerOffset.X = token.InnerOffsets[l] = this.GetInnerOffsetX(font, t, l + 1, alignment);
innerOffset.Y += font.LineHeight;
} else {
innerOffset.X += size.X;
}
}
token.Area = area.ToArray();
}
}
private float GetInnerOffsetX(GenericFont defaultFont, int tokenIndex, int lineIndex, TextAlignment alignment) {
if (alignment > TextAlignment.Left) {
var token = this.Tokens[tokenIndex];
var tokenFont = token.GetFont(defaultFont);
// if we're the last line in our line array, then we don't contain a line split, so the line ends in a later token
var endsLater = lineIndex >= token.SplitDisplayString.Length - 1;
var tokenWidth = lineIndex <= 0 ? token.GetSelfWidth(tokenFont) : 0;
var endsLater = this.EndsLater(tokenIndex, lineIndex);
// if the line ends in our token, we should ignore trailing white space
var restOfLine = tokenFont.MeasureString(token.SplitDisplayString[lineIndex], !endsLater).X;
var restOfLine = tokenFont.MeasureString(token.SplitDisplayString[lineIndex], !endsLater).X + tokenWidth;
if (endsLater) {
for (var i = tokenIndex + 1; i < this.Tokens.Length; i++) {
var other = this.Tokens[i];
var otherFont = other.GetFont(defaultFont);
if (other.SplitDisplayString.Length > 1) {
// the line ends in this token (so we also ignore trailing whitespaces)
restOfLine += otherFont.MeasureString(other.SplitDisplayString[0], true).X;
restOfLine += otherFont.MeasureString(other.SplitDisplayString[0], !this.EndsLater(i, 0)).X + other.GetSelfWidth(otherFont);
// if the token's split display string has multiple lines, then the line ends in it, which means we can stop
if (other.SplitDisplayString.Length > 1)
break;
} else {
// the line doesn't end in this token (or it's the last token), so add it fully
var lastToken = i >= this.Tokens.Length - 1;
restOfLine += otherFont.MeasureString(other.DisplayString, lastToken).X;
}
}
}
if (alignment == TextAlignment.Center)
@ -242,13 +248,16 @@ namespace MLEM.Formatting {
return 0;
}
private GenericFont GetFontForIndex(GenericFont font, int index) {
foreach (var token in this.Tokens) {
index -= token.Substring.Length;
if (index <= 0)
return token.GetFont(font);
private bool EndsLater(int tokenIndex, int lineIndex) {
// if we're the last line in our line array, then we don't contain a line split, so the line ends in a later token
return lineIndex >= this.Tokens[tokenIndex].SplitDisplayString.Length - 1 && tokenIndex < this.Tokens.Length - 1;
}
return null;
private IEnumerable<GenericFont.DecoratedCodePointSource> AsDecoratedSources(GenericFont font) {
return this.Tokens.Select(t => {
var tokenFont = t.GetFont(font);
return new GenericFont.DecoratedCodePointSource(new CodePointSource(t.Substring), tokenFont, t.GetSelfWidth(tokenFont));
});
}
}

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
@ -5,8 +6,8 @@ using MLEM.Textures;
namespace MLEM.Graphics {
/// <summary>
/// This class contains a <see cref="DrawAutoTile"/> method that allows users to easily draw a tile with automatic connections, as well as a more complex <see cref="DrawExtendedAutoTile"/> method.
/// Note that <see cref="StaticSpriteBatch"/> can also be used for drawing by using the <see cref="AddAutoTile"/> and <see cref="AddExtendedAutoTile"/> methods instead.
/// This class contains a <see cref="DrawAutoTile"/> method that allows users to easily draw a tile with automatic connections, as well as a more complex <see cref="DrawExtendedAutoTile(Microsoft.Xna.Framework.Graphics.SpriteBatch,Microsoft.Xna.Framework.Vector2,MLEM.Textures.TextureRegion,MLEM.Textures.TextureRegion,MLEM.Graphics.AutoTiling.ConnectsTo,Microsoft.Xna.Framework.Color,Microsoft.Xna.Framework.Color,System.Nullable{Microsoft.Xna.Framework.Vector2},System.Nullable{Microsoft.Xna.Framework.Vector2},float,float)"/> method.
/// Note that <see cref="StaticSpriteBatch"/> can also be used for drawing by using the <see cref="AddAutoTile"/> and <see cref="AddExtendedAutoTile(MLEM.Graphics.StaticSpriteBatch,Microsoft.Xna.Framework.Vector2,MLEM.Textures.TextureRegion,MLEM.Textures.TextureRegion,MLEM.Graphics.AutoTiling.ConnectsTo,Microsoft.Xna.Framework.Color,Microsoft.Xna.Framework.Color,System.Nullable{Microsoft.Xna.Framework.Vector2},System.Nullable{Microsoft.Xna.Framework.Vector2},float,float,System.Collections.Generic.ICollection{MLEM.Graphics.StaticSpriteBatch.Item})"/> methods instead.
/// </summary>
public static class AutoTiling {
@ -88,7 +89,7 @@ namespace MLEM.Graphics {
var orig = origin ?? Vector2.Zero;
var sc = scale ?? Vector2.One;
var od = layerDepth + overlayDepthOffset;
var (r1, r2, r3, r4) = AutoTiling.CalculateExtendedAutoTile(pos, overlayTexture.Area, connectsTo, sc);
var (r1, r2, r3, r4) = AutoTiling.CalculateExtendedAutoTile(overlayTexture.Area, connectsTo);
if (backgroundTexture != null)
batch.Draw(backgroundTexture, pos, backgroundColor, 0, orig, sc, SpriteEffects.None, layerDepth);
if (r1 != Rectangle.Empty)
@ -101,12 +102,30 @@ namespace MLEM.Graphics {
batch.Draw(overlayTexture.Texture, pos, r4, overlayColor, 0, orig, sc, SpriteEffects.None, od);
}
/// <inheritdoc cref="DrawExtendedAutoTile"/>
/// <inheritdoc cref="DrawExtendedAutoTile(Microsoft.Xna.Framework.Graphics.SpriteBatch,Microsoft.Xna.Framework.Vector2,MLEM.Textures.TextureRegion,MLEM.Textures.TextureRegion,MLEM.Graphics.AutoTiling.ConnectsTo,Microsoft.Xna.Framework.Color,Microsoft.Xna.Framework.Color,System.Nullable{Microsoft.Xna.Framework.Vector2},System.Nullable{Microsoft.Xna.Framework.Vector2},float,float)"/>
public static void DrawExtendedAutoTile(SpriteBatch batch, Vector2 pos, TextureRegion backgroundTexture, Func<int, TextureRegion> overlayTextures, ConnectsTo connectsTo, Color backgroundColor, Color overlayColor, Vector2? origin = null, Vector2? scale = null, float layerDepth = 0, float overlayDepthOffset = 0) {
var orig = origin ?? Vector2.Zero;
var sc = scale ?? Vector2.One;
var od = layerDepth + overlayDepthOffset;
var (xUl, xUr, xDl, xDr) = AutoTiling.CalculateExtendedAutoTileOffsets(connectsTo);
if (backgroundTexture != null)
batch.Draw(backgroundTexture, pos, backgroundColor, 0, orig, sc, SpriteEffects.None, layerDepth);
if (xUl >= 0)
batch.Draw(overlayTextures(xUl), pos, overlayColor, 0, orig, sc, SpriteEffects.None, od);
if (xUr >= 0)
batch.Draw(overlayTextures(xUr), pos, overlayColor, 0, orig, sc, SpriteEffects.None, od);
if (xDl >= 0)
batch.Draw(overlayTextures(xDl), pos, overlayColor, 0, orig, sc, SpriteEffects.None, od);
if (xDr >= 0)
batch.Draw(overlayTextures(xDr), pos, overlayColor, 0, orig, sc, SpriteEffects.None, od);
}
/// <inheritdoc cref="DrawExtendedAutoTile(Microsoft.Xna.Framework.Graphics.SpriteBatch,Microsoft.Xna.Framework.Vector2,MLEM.Textures.TextureRegion,MLEM.Textures.TextureRegion,MLEM.Graphics.AutoTiling.ConnectsTo,Microsoft.Xna.Framework.Color,Microsoft.Xna.Framework.Color,System.Nullable{Microsoft.Xna.Framework.Vector2},System.Nullable{Microsoft.Xna.Framework.Vector2},float,float)"/>
public static void AddExtendedAutoTile(StaticSpriteBatch batch, Vector2 pos, TextureRegion backgroundTexture, TextureRegion overlayTexture, ConnectsTo connectsTo, Color backgroundColor, Color overlayColor, Vector2? origin = null, Vector2? scale = null, float layerDepth = 0, float overlayDepthOffset = 0, ICollection<StaticSpriteBatch.Item> items = null) {
var orig = origin ?? Vector2.Zero;
var sc = scale ?? Vector2.One;
var od = layerDepth + overlayDepthOffset;
var (r1, r2, r3, r4) = AutoTiling.CalculateExtendedAutoTile(pos, overlayTexture.Area, connectsTo, sc);
var (r1, r2, r3, r4) = AutoTiling.CalculateExtendedAutoTile(overlayTexture.Area, connectsTo);
if (backgroundTexture != null) {
var background = batch.Add(backgroundTexture, pos, backgroundColor, 0, orig, sc, SpriteEffects.None, layerDepth);
items?.Add(background);
@ -129,6 +148,34 @@ namespace MLEM.Graphics {
}
}
/// <inheritdoc cref="DrawExtendedAutoTile(Microsoft.Xna.Framework.Graphics.SpriteBatch,Microsoft.Xna.Framework.Vector2,MLEM.Textures.TextureRegion,Func{int,MLEM.Textures.TextureRegion},MLEM.Graphics.AutoTiling.ConnectsTo,Microsoft.Xna.Framework.Color,Microsoft.Xna.Framework.Color,System.Nullable{Microsoft.Xna.Framework.Vector2},System.Nullable{Microsoft.Xna.Framework.Vector2},float,float)"/>
public static void AddExtendedAutoTile(StaticSpriteBatch batch, Vector2 pos, TextureRegion backgroundTexture, Func<int, TextureRegion> overlayTextures, ConnectsTo connectsTo, Color backgroundColor, Color overlayColor, Vector2? origin = null, Vector2? scale = null, float layerDepth = 0, float overlayDepthOffset = 0, ICollection<StaticSpriteBatch.Item> items = null) {
var orig = origin ?? Vector2.Zero;
var sc = scale ?? Vector2.One;
var od = layerDepth + overlayDepthOffset;
var (xUl, xUr, xDl, xDr) = AutoTiling.CalculateExtendedAutoTileOffsets(connectsTo);
if (backgroundTexture != null) {
var background = batch.Add(backgroundTexture, pos, backgroundColor, 0, orig, sc, SpriteEffects.None, layerDepth);
items?.Add(background);
}
if (xUl >= 0) {
var o1 = batch.Add(overlayTextures(xUl), pos, overlayColor, 0, orig, sc, SpriteEffects.None, od);
items?.Add(o1);
}
if (xUr >= 0) {
var o2 = batch.Add(overlayTextures(xUr), pos, overlayColor, 0, orig, sc, SpriteEffects.None, od);
items?.Add(o2);
}
if (xDl >= 0) {
var o3 = batch.Add(overlayTextures(xDl), pos, overlayColor, 0, orig, sc, SpriteEffects.None, od);
items?.Add(o3);
}
if (xDr >= 0) {
var o4 = batch.Add(overlayTextures(xDr), pos, overlayColor, 0, orig, sc, SpriteEffects.None, od);
items?.Add(o4);
}
}
private static (Vector2, Rectangle, Vector2, Rectangle, Vector2, Rectangle, Vector2, Rectangle) CalculateAutoTile(Vector2 pos, Rectangle textureRegion, ConnectsTo connectsTo, Vector2 scale) {
var up = connectsTo(0, -1);
var down = connectsTo(0, 1);
@ -147,15 +194,20 @@ namespace MLEM.Graphics {
new Vector2(pos.X + w2 * scale.X, pos.Y + h2 * scale.Y), new Rectangle(textureRegion.X + w2 + xDr * w, textureRegion.Y + h2, w2, h2));
}
private static (Rectangle, Rectangle, Rectangle, Rectangle) CalculateExtendedAutoTile(Vector2 pos, Rectangle textureRegion, ConnectsTo connectsTo, Vector2 scale) {
private static (int, int, int, int) CalculateExtendedAutoTileOffsets(ConnectsTo connectsTo) {
var up = connectsTo(0, -1);
var down = connectsTo(0, 1);
var left = connectsTo(-1, 0);
var right = connectsTo(1, 0);
var xUl = up && left ? connectsTo(-1, -1) ? -1 : 12 : left ? 0 : up ? 8 : 4;
var xUr = up && right ? connectsTo(1, -1) ? -1 : 13 : right ? 1 : up ? 9 : 5;
var xDl = down && left ? connectsTo(-1, 1) ? -1 : 14 : left ? 2 : down ? 10 : 6;
var xDr = down && right ? connectsTo(1, 1) ? -1 : 15 : right ? 3 : down ? 11 : 7;
return (
up && left ? connectsTo(-1, -1) ? -1 : 12 : left ? 0 : up ? 8 : 4,
up && right ? connectsTo(1, -1) ? -1 : 13 : right ? 1 : up ? 9 : 5,
down && left ? connectsTo(-1, 1) ? -1 : 14 : left ? 2 : down ? 10 : 6,
down && right ? connectsTo(1, 1) ? -1 : 15 : right ? 3 : down ? 11 : 7);
}
private static (Rectangle, Rectangle, Rectangle, Rectangle) CalculateExtendedAutoTile(Rectangle textureRegion, ConnectsTo connectsTo) {
var (xUl, xUr, xDl, xDr) = AutoTiling.CalculateExtendedAutoTileOffsets(connectsTo);
var (w, h) = (textureRegion.Width, textureRegion.Height);
return (
xUl < 0 ? Rectangle.Empty : new Rectangle(textureRegion.X + xUl * w, textureRegion.Y, w, h),

View file

@ -1,14 +1,16 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
#if FNA
using MLEM.Extensions;
using System.IO;
#endif
namespace MLEM.Graphics {
/// <summary>
/// A static sprite batch is a variation of <see cref="SpriteBatch"/> that keeps all batched items in a <see cref="VertexBuffer"/>, allowing for them to be drawn multiple times.
/// A static sprite batch is a highly optimized variation of <see cref="SpriteBatch"/> that keeps all batched items in a <see cref="VertexBuffer"/>, allowing for them to be drawn multiple times.
/// To add items to a static sprite batch, use <see cref="BeginBatch"/> to begin batching, <see cref="ClearBatch"/> to clear currently batched items, <c>Add</c> and its various overloads to add batch items, <see cref="Remove"/> to remove them again, and <see cref="EndBatch"/> to end batching.
/// To draw the batched items, call <see cref="Draw"/>.
/// </summary>
@ -21,7 +23,7 @@ namespace MLEM.Graphics {
/// <summary>
/// The amount of vertices that are currently batched.
/// </summary>
public int Vertices => this.items.Count * 4;
public int Vertices => this.itemAmount * 4;
/// <summary>
/// The amount of vertex buffers that this static sprite batch has.
/// To see the amount of buffers that are actually in use, see <see cref="FilledBuffers"/>.
@ -39,13 +41,15 @@ namespace MLEM.Graphics {
private readonly GraphicsDevice graphicsDevice;
private readonly SpriteEffect spriteEffect;
private readonly List<VertexBuffer> vertexBuffers = new List<VertexBuffer>();
private readonly List<DynamicVertexBuffer> vertexBuffers = new List<DynamicVertexBuffer>();
private readonly List<Texture2D> textures = new List<Texture2D>();
private readonly ISet<Item> items = new HashSet<Item>();
private readonly SortedDictionary<float, ItemSet> items = new SortedDictionary<float, ItemSet>();
private SpriteSortMode sortMode = SpriteSortMode.Texture;
private IndexBuffer indices;
private bool batching;
private bool batchChanged;
private int itemAmount;
/// <summary>
/// Creates a new static sprite batch with the given <see cref="GraphicsDevice"/>
@ -60,10 +64,27 @@ namespace MLEM.Graphics {
/// Begins batching.
/// Call this method before calling <c>Add</c> or any of its overloads.
/// </summary>
/// <param name="sortMode">The drawing order for sprite drawing. When <see langword="null"/> is passed, the last used sort mode will be used again. The initial sort mode is <see cref="SpriteSortMode.Texture"/>. Note that <see cref="SpriteSortMode.Immediate"/> is not supported.</param>
/// <exception cref="InvalidOperationException">Thrown if this batch is currently batching already</exception>
public void BeginBatch() {
/// <exception cref="ArgumentOutOfRangeException">Thrown if the <paramref name="sortMode"/> is <see cref="SpriteSortMode.Immediate"/>, which is not supported.</exception>
public void BeginBatch(SpriteSortMode? sortMode = null) {
if (this.batching)
throw new InvalidOperationException("Already batching");
if (sortMode == SpriteSortMode.Immediate)
throw new ArgumentOutOfRangeException(nameof(sortMode), "Cannot use sprite sort mode Immediate for static batching");
// if the sort mode changed (which should be very rare in practice), we have to re-sort our list
if (sortMode != null && this.sortMode != sortMode) {
this.sortMode = sortMode.Value;
if (this.items.Count > 0) {
var tempItems = this.items.Values.SelectMany(s => s.Items).ToArray();
this.items.Clear();
foreach (var item in tempItems)
this.AddItemToSet(item);
this.batchChanged = true;
}
}
this.batching = true;
}
@ -71,14 +92,10 @@ namespace MLEM.Graphics {
/// Ends batching.
/// Call this method after calling <c>Add</c> or any of its overloads the desired number of times to add batched items.
/// </summary>
/// <param name="sortMode">The drawing order for sprite drawing. <see cref="SpriteSortMode.Texture" /> by default, since it is the best in terms of rendering performance. Note that <see cref="SpriteSortMode.Immediate"/> is not supported.</param>
/// <exception cref="InvalidOperationException">Thrown if this method is called before <see cref="BeginBatch"/> was called.</exception>
/// <exception cref="ArgumentOutOfRangeException">Thrown if the <paramref name="sortMode"/> is <see cref="SpriteSortMode.Immediate"/>, which is not supported.</exception>
public void EndBatch(SpriteSortMode sortMode = SpriteSortMode.Texture) {
public void EndBatch() {
if (!this.batching)
throw new InvalidOperationException("Not batching");
if (sortMode == SpriteSortMode.Immediate)
throw new ArgumentOutOfRangeException(nameof(sortMode), "Cannot use sprite sort mode Immediate for static batching");
this.batching = false;
// if we didn't add or remove any batch items, we don't have to recalculate anything
@ -88,25 +105,11 @@ namespace MLEM.Graphics {
this.FilledBuffers = 0;
this.textures.Clear();
// order items according to the sort mode
IEnumerable<Item> ordered = this.items;
switch (sortMode) {
case SpriteSortMode.Texture:
// SortingKey is internal, but this will do for batching the same texture together
ordered = ordered.OrderBy(i => i.Texture.GetHashCode());
break;
case SpriteSortMode.BackToFront:
ordered = ordered.OrderBy(i => -i.Depth);
break;
case SpriteSortMode.FrontToBack:
ordered = ordered.OrderBy(i => i.Depth);
break;
}
// fill vertex buffers
var dataIndex = 0;
Texture2D texture = null;
foreach (var item in ordered) {
foreach (var itemSet in this.items.Values) {
foreach (var item in itemSet.Items) {
// if the texture changes, we also have to start a new buffer!
if (dataIndex > 0 && (item.Texture != texture || dataIndex >= StaticSpriteBatch.Data.Length)) {
this.FillBuffer(this.FilledBuffers++, texture, StaticSpriteBatch.Data);
@ -118,11 +121,12 @@ namespace MLEM.Graphics {
StaticSpriteBatch.Data[dataIndex++] = item.BottomRight;
texture = item.Texture;
}
}
if (dataIndex > 0)
this.FillBuffer(this.FilledBuffers++, texture, StaticSpriteBatch.Data);
// ensure we have enough indices
var maxItems = Math.Min(this.items.Count, StaticSpriteBatch.MaxBatchItems);
var maxItems = Math.Min(this.itemAmount, StaticSpriteBatch.MaxBatchItems);
// each item has 2 triangles which each have 3 indices
if (this.indices == null || this.indices.IndexCount < 6 * maxItems) {
var newIndices = new short[6 * maxItems];
@ -176,7 +180,7 @@ namespace MLEM.Graphics {
for (var i = 0; i < this.FilledBuffers; i++) {
var buffer = this.vertexBuffers[i];
var texture = this.textures[i];
var verts = Math.Min(this.items.Count * 4 - totalIndex, buffer.VertexCount);
var verts = Math.Min(this.itemAmount * 4 - totalIndex, buffer.VertexCount);
this.graphicsDevice.SetVertexBuffer(buffer);
if (effect != null) {
@ -350,6 +354,21 @@ namespace MLEM.Graphics {
return this.Add(texture, destinationRectangle, null, color);
}
/// <summary>
/// Adds an item to this batch.
/// Note that this batch needs to currently be batching, meaning <see cref="BeginBatch"/> has to have been called previously.
/// </summary>
/// <param name="item">The item to add.</param>
/// <returns>The added <paramref name="item"/>, for chaining.</returns>
public Item Add(Item item) {
if (!this.batching)
throw new InvalidOperationException("Not batching");
this.AddItemToSet(item);
this.itemAmount++;
this.batchChanged = true;
return item;
}
/// <summary>
/// Removes the given item from this batch.
/// Note that this batch needs to currently be batching, meaning <see cref="BeginBatch"/> has to have been called previously.
@ -360,7 +379,11 @@ namespace MLEM.Graphics {
public bool Remove(Item item) {
if (!this.batching)
throw new InvalidOperationException("Not batching");
if (this.items.Remove(item)) {
var key = item.GetSortKey(this.sortMode);
if (this.items.TryGetValue(key, out var itemSet) && itemSet.Remove(item)) {
if (itemSet.IsEmpty)
this.items.Remove(key);
this.itemAmount--;
this.batchChanged = true;
return true;
}
@ -378,6 +401,7 @@ namespace MLEM.Graphics {
this.items.Clear();
this.textures.Clear();
this.FilledBuffers = 0;
this.itemAmount = 0;
this.batchChanged = true;
}
@ -387,7 +411,6 @@ namespace MLEM.Graphics {
this.indices?.Dispose();
foreach (var buffer in this.vertexBuffers)
buffer.Dispose();
GC.SuppressFinalize(this);
}
private Item Add(Texture2D texture, Vector2 pos, Vector2 offset, Vector2 size, float sin, float cos, Color color, Vector2 texTl, Vector2 texBr, float depth) {
@ -427,27 +450,31 @@ namespace MLEM.Graphics {
}
private Item Add(Texture2D texture, float depth, VertexPositionColorTexture tl, VertexPositionColorTexture tr, VertexPositionColorTexture bl, VertexPositionColorTexture br) {
if (!this.batching)
throw new InvalidOperationException("Not batching");
var item = new Item(texture, depth, tl, tr, bl, br);
this.items.Add(item);
this.batchChanged = true;
return item;
return this.Add(new Item(texture, depth, tl, tr, bl, br));
}
private void FillBuffer(int index, Texture2D texture, VertexPositionColorTexture[] data) {
if (this.vertexBuffers.Count <= index)
this.vertexBuffers.Add(new VertexBuffer(this.graphicsDevice, VertexPositionColorTexture.VertexDeclaration, StaticSpriteBatch.MaxBatchItems * 4, BufferUsage.WriteOnly));
this.vertexBuffers[index].SetData(data);
this.vertexBuffers.Add(new DynamicVertexBuffer(this.graphicsDevice, VertexPositionColorTexture.VertexDeclaration, StaticSpriteBatch.MaxBatchItems * 4, BufferUsage.WriteOnly));
this.vertexBuffers[index].SetData(data, 0, data.Length, SetDataOptions.Discard);
this.textures.Insert(index, texture);
}
private void DrawPrimitives(int vertices) {
#if FNA
#if FNA
this.graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, vertices, 0, vertices / 4 * 2);
#else
#else
this.graphicsDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, vertices / 4 * 2);
#endif
#endif
}
private void AddItemToSet(Item item) {
var sortKey = item.GetSortKey(this.sortMode);
if (!this.items.TryGetValue(sortKey, out var itemSet)) {
itemSet = new ItemSet();
this.items.Add(sortKey, itemSet);
}
itemSet.Add(item);
}
/// <summary>
@ -472,9 +499,68 @@ namespace MLEM.Graphics {
this.BottomRight = bottomRight;
}
internal float GetSortKey(SpriteSortMode sortMode) {
switch (sortMode) {
case SpriteSortMode.Texture:
return this.Texture.GetHashCode();
case SpriteSortMode.BackToFront:
return -this.Depth;
case SpriteSortMode.FrontToBack:
return this.Depth;
default:
return 0;
}
}
#if FNA
}
private class ItemSet {
public IEnumerable<Item> Items {
get {
if (this.items != null)
return this.items;
if (this.single != null)
return Enumerable.Repeat(this.single, 1);
return Enumerable.Empty<Item>();
}
}
public bool IsEmpty => this.items == null && this.single == null;
private HashSet<Item> items;
private Item single;
public void Add(Item item) {
if (this.items != null) {
this.items.Add(item);
} else if (this.single != null) {
this.items = new HashSet<Item>();
this.items.Add(this.single);
this.items.Add(item);
this.single = null;
} else {
this.single = item;
}
}
public bool Remove(Item item) {
if (this.items != null && this.items.Remove(item)) {
if (this.items.Count <= 1) {
this.single = this.items.Single();
this.items = null;
}
return true;
} else if (this.single == item) {
this.single = null;
return true;
} else {
return false;
}
}
}
#if FNA
private class SpriteEffect : Effect {
private EffectParameter matrixParam;
@ -525,7 +611,7 @@ namespace MLEM.Graphics {
}
}
#endif
#endif
}
}

View file

@ -6,20 +6,41 @@ using Microsoft.Xna.Framework;
using Microsoft.Xna.Framework.Graphics;
using Microsoft.Xna.Framework.Input;
using Microsoft.Xna.Framework.Input.Touch;
using MLEM.Misc;
using static MLEM.Input.GenericInput;
namespace MLEM.Input {
/// <summary>
/// An input handler is a more advanced wrapper around MonoGame's default input system.
/// It includes keyboard, mouse, gamepad and touch states, as well as a new "pressed" state for keys and the ability for keyboard and gamepad repeat events.
/// It includes keyboard, mouse, gamepad and touch handling through the <see cref="GenericInput"/> wrapper, as well as a new "pressed" state for inputs, the ability for keyboard and gamepad repeat events, and the ability to track down, up and press times for inputs.
/// </summary>
public class InputHandler : GameComponent {
#if FNA
/// <summary>
/// All values of the <see cref="Buttons"/> enum.
/// </summary>
public static readonly Buttons[] AllButtons =
#if NET6_0_OR_GREATER
Enum.GetValues<Buttons>();
#else
(Buttons[]) Enum.GetValues(typeof(Buttons));
#endif
/// <summary>
/// All values of the <see cref="Keys"/> enum.
/// </summary>
public static readonly Keys[] AllKeys =
#if NET6_0_OR_GREATER
Enum.GetValues<Keys>();
#else
(Keys[]) Enum.GetValues(typeof(Keys));
#endif
#if FNA
private const int MaximumGamePadCount = 4;
#else
#else
private static readonly int MaximumGamePadCount = GamePad.MaximumGamePadCount;
#endif
#endif
private static readonly TouchLocation[] EmptyTouchLocations = new TouchLocation[0];
private static readonly GenericInput[] EmptyGenericInputs = new GenericInput[0];
/// <summary>
/// Contains all of the gestures that have finished during the last update call.
@ -72,25 +93,31 @@ namespace MLEM.Input {
/// Inverted behavior means that, instead of an input counting as pressed when it was up in the last frame and is now down, it will be counted as pressed when it was down in the last frame and is now up.
/// </summary>
public bool InvertPressBehavior;
/// <summary>
/// If your project already handles the processing of MonoGame's gestures elsewhere, you can set this field to true to ensure that this input handler's gesture handling does not override your own, since <see cref="GestureSample"/> objects can only be retrieved once and are then removed from the <see cref="TouchPanel"/>'s queue.
/// If this value is set to true, but you still want to be able to use <see cref="Gestures"/>, <see cref="GetGesture"/>, and <see cref="GetViewportGesture"/>, you can make this input handler aware of a gesture for the duration of the update frame that you added it on by using <see cref="AddExternalGesture"/>.
/// For more info, see https://mlem.ellpeck.de/articles/input.html#external-gesture-handling.
/// </summary>
public bool ExternalGestureHandling;
/// <summary>
/// An array of all <see cref="Keys"/>, <see cref="Buttons"/> and <see cref="MouseButton"/> values that are currently down.
/// Additionally, <see cref="TryGetDownTime"/> or <see cref="GetDownTime"/> can be used to determine the amount of time that a given input has been down for.
/// </summary>
public GenericInput[] InputsDown { get; private set; } = Array.Empty<GenericInput>();
public GenericInput[] InputsDown { get; private set; } = InputHandler.EmptyGenericInputs;
/// <summary>
/// An array of all <see cref="Keys"/>, <see cref="Buttons"/> and <see cref="MouseButton"/> that are currently considered pressed.
/// An input is considered pressed if it was up in the last update, and is up in the current one.
/// </summary>
public GenericInput[] InputsPressed { get; private set; } = Array.Empty<GenericInput>();
public GenericInput[] InputsPressed { get; private set; } = InputHandler.EmptyGenericInputs;
/// <summary>
/// Contains the touch state from the last update call
/// </summary>
public TouchCollection LastTouchState { get; private set; } = new TouchCollection(Array.Empty<TouchLocation>());
public TouchCollection LastTouchState { get; private set; } = new TouchCollection(InputHandler.EmptyTouchLocations);
/// <summary>
/// Contains the current touch state
/// </summary>
public TouchCollection TouchState { get; private set; } = new TouchCollection(Array.Empty<TouchLocation>());
public TouchCollection TouchState { get; private set; } = new TouchCollection(InputHandler.EmptyTouchLocations);
/// <summary>
/// Contains the <see cref="LastTouchState"/>, but with the <see cref="GraphicsDevice.Viewport"/> taken into account.
/// </summary>
@ -100,8 +127,8 @@ namespace MLEM.Input {
/// </summary>
public IList<TouchLocation> ViewportTouchState { get; private set; }
/// <summary>
/// Contains the amount of gamepads that are currently connected.
/// This field is automatically updated in <see cref="Update()"/>
/// Contains the amount of gamepads that are currently connected. Note that this value will be set to 0 if <see cref="HandleGamepads"/> is false.
/// This field is automatically updated in <see cref="Update()"/>.
/// </summary>
public int ConnectedGamepads { get; private set; }
/// <summary>
@ -153,6 +180,7 @@ namespace MLEM.Input {
private readonly List<GestureSample> gestures = new List<GestureSample>();
private readonly HashSet<(GenericInput, int)> consumedPresses = new HashSet<(GenericInput, int)>();
private readonly Dictionary<(GenericInput, int), DateTime> inputUpTimes = new Dictionary<(GenericInput, int), DateTime>();
private readonly Dictionary<(GenericInput, int), DateTime> inputDownTimes = new Dictionary<(GenericInput, int), DateTime>();
private readonly Dictionary<(GenericInput, int), DateTime> inputPressedTimes = new Dictionary<(GenericInput, int), DateTime>();
private Point ViewportOffset => new Point(-this.Game.GraphicsDevice.Viewport.X, -this.Game.GraphicsDevice.Viewport.Y);
@ -188,15 +216,15 @@ namespace MLEM.Input {
this.consumedPresses.Clear();
if (this.HandleKeyboard) {
this.LastKeyboardState = this.KeyboardState;
if (this.HandleKeyboard) {
this.KeyboardState = active ? Keyboard.GetState() : default;
var pressedKeys = this.KeyboardState.GetPressedKeys();
foreach (var pressed in pressedKeys)
this.AccumulateDown(pressed, -1);
if (this.HandleKeyboardRepeats) {
this.triggerKeyRepeat = false;
if (this.HandleKeyboardRepeats) {
// the key that started being held most recently should be the one being repeated
this.heldKey = pressedKeys.OrderBy(k => this.GetDownTime(k)).FirstOrDefault();
if (this.TryGetDownTime(this.heldKey, out var heldTime)) {
@ -211,11 +239,17 @@ namespace MLEM.Input {
}
}
}
} else {
this.heldKey = Keys.None;
}
} else {
this.KeyboardState = default;
this.triggerKeyRepeat = false;
this.heldKey = Keys.None;
}
if (this.HandleMouse) {
this.LastMouseState = this.MouseState;
if (this.HandleMouse) {
var state = Mouse.GetState();
if (active && this.Game.GraphicsDevice.Viewport.Bounds.Contains(state.X, state.Y)) {
this.MouseState = state;
@ -225,12 +259,14 @@ namespace MLEM.Input {
}
} else {
// mouse position and scroll wheel value should be preserved when the mouse is out of bounds
#if FNA
#if FNA
this.MouseState = new MouseState(state.X, state.Y, state.ScrollWheelValue, 0, 0, 0, 0, 0);
#else
#else
this.MouseState = new MouseState(state.X, state.Y, state.ScrollWheelValue, 0, 0, 0, 0, 0, state.HorizontalScrollWheelValue);
#endif
#endif
}
} else {
this.MouseState = default;
}
if (this.HandleGamepads) {
@ -241,8 +277,8 @@ namespace MLEM.Input {
if (GamePad.GetCapabilities((PlayerIndex) i).IsConnected) {
if (active) {
this.gamepads[i] = GamePad.GetState((PlayerIndex) i);
foreach (var button in EnumHelper.Buttons) {
if (this.IsGamepadButtonDown(button, i))
foreach (var button in InputHandler.AllButtons) {
if (this.IsDown(button, i))
this.AccumulateDown(button, i);
}
}
@ -251,11 +287,11 @@ namespace MLEM.Input {
}
}
if (this.HandleGamepadRepeats) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
this.triggerGamepadButtonRepeat[i] = false;
this.heldGamepadButtons[i] = EnumHelper.Buttons
.Where(b => this.IsGamepadButtonDown(b, i))
if (this.HandleGamepadRepeats) {
this.heldGamepadButtons[i] = InputHandler.AllButtons
.Where(b => this.IsDown(b, i))
.OrderBy(b => this.GetDownTime(b, i))
.Cast<Buttons?>().FirstOrDefault();
if (this.heldGamepadButtons[i].HasValue && this.TryGetDownTime(this.heldGamepadButtons[i].Value, out var heldTime, i)) {
@ -267,15 +303,24 @@ namespace MLEM.Input {
}
}
}
} else {
this.heldGamepadButtons[i] = null;
}
}
} else {
this.ConnectedGamepads = 0;
for (var i = 0; i < InputHandler.MaximumGamePadCount; i++) {
this.lastGamepads[i] = this.gamepads[i];
this.gamepads[i] = default;
this.triggerGamepadButtonRepeat[i] = false;
this.heldGamepadButtons[i] = null;
}
}
if (this.HandleTouch) {
this.LastTouchState = this.TouchState;
this.LastViewportTouchState = this.ViewportTouchState;
this.TouchState = active ? TouchPanel.GetState() : new TouchCollection(Array.Empty<TouchLocation>());
if (this.HandleTouch) {
this.TouchState = active ? TouchPanel.GetState() : new TouchCollection(InputHandler.EmptyTouchLocations);
if (this.TouchState.Count > 0 && this.ViewportOffset != Point.Zero) {
this.ViewportTouchState = new List<TouchLocation>();
foreach (var touch in this.TouchState) {
@ -287,14 +332,20 @@ namespace MLEM.Input {
this.ViewportTouchState = this.TouchState;
}
// we still want to clear gestures when handling externally to maintain the per-frame gesture system
this.gestures.Clear();
while (active && TouchPanel.IsGestureAvailable)
if (active && !this.ExternalGestureHandling) {
while (TouchPanel.IsGestureAvailable)
this.gestures.Add(TouchPanel.ReadGesture());
}
} else {
this.TouchState = new TouchCollection(InputHandler.EmptyTouchLocations);
this.gestures.Clear();
}
if (this.inputsDownAccum.Count <= 0 && this.inputsDown.Count <= 0) {
this.InputsPressed = Array.Empty<GenericInput>();
this.InputsDown = Array.Empty<GenericInput>();
this.InputsPressed = InputHandler.EmptyGenericInputs;
this.InputsDown = InputHandler.EmptyGenericInputs;
} else {
// handle pressed inputs
var pressed = new List<GenericInput>();
@ -307,13 +358,15 @@ namespace MLEM.Input {
}
this.InputsPressed = pressed.ToArray();
// handle inputs that changed to up
foreach (var key in this.inputsDownAccum.Keys)
this.inputUpTimes.Remove(key);
// handle inputs that changed between down and up
foreach (var key in this.inputsDown.Keys) {
if (!this.inputsDownAccum.ContainsKey(key))
this.inputUpTimes[key] = DateTime.UtcNow;
}
foreach (var key in this.inputsDownAccum.Keys) {
if (!this.inputsDown.ContainsKey(key))
this.inputDownTimes[key] = DateTime.UtcNow;
}
// handle inputs that are currently down
this.InputsDown = this.inputsDownAccum.Keys.Select(key => key.Item1).ToArray();
@ -346,22 +399,39 @@ namespace MLEM.Input {
return this.gamepads[index];
}
/// <summary>
/// Returns whether the given modifier key is down.
/// </summary>
/// <param name="modifier">The modifier key</param>
/// <returns>If the modifier key is down</returns>
public bool IsModifierKeyDown(ModifierKey modifier) {
foreach (var key in modifier.GetKeys()) {
if (this.IsDown(key))
return true;
}
return false;
}
/// <inheritdoc cref="Microsoft.Xna.Framework.Input.KeyboardState.IsKeyDown"/>
[Obsolete("This method is deprecated. Use the GenericInput version IsDown instead.")]
public bool IsKeyDown(Keys key) {
return this.KeyboardState.IsKeyDown(key);
}
/// <inheritdoc cref="Microsoft.Xna.Framework.Input.KeyboardState.IsKeyUp"/>
[Obsolete("This method is deprecated. Use the GenericInput version IsUp instead.")]
public bool IsKeyUp(Keys key) {
return this.KeyboardState.IsKeyUp(key);
}
/// <inheritdoc cref="Microsoft.Xna.Framework.Input.KeyboardState.IsKeyDown"/>
[Obsolete("This method is deprecated. Use the GenericInput version WasDown instead.")]
public bool WasKeyDown(Keys key) {
return this.LastKeyboardState.IsKeyDown(key);
}
/// <inheritdoc cref="Microsoft.Xna.Framework.Input.KeyboardState.IsKeyUp"/>
[Obsolete("This method is deprecated. Use the GenericInput version WasUp instead.")]
public bool WasKeyUp(Keys key) {
return this.LastKeyboardState.IsKeyUp(key);
}
@ -373,6 +443,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="key">The key to query</param>
/// <returns>If the key is pressed</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsPressed instead.")]
public bool IsKeyPressed(Keys key) {
// if the queried key is the held key and a repeat should be triggered, return true
if (this.HandleKeyboardRepeats && key == this.heldKey && this.triggerKeyRepeat)
@ -388,6 +459,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="key">The key to query</param>
/// <returns>If the key is pressed</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsPressedIgnoreRepeats instead.")]
public bool IsKeyPressedIgnoreRepeats(Keys key) {
if (this.InvertPressBehavior)
return this.WasKeyDown(key) && this.IsKeyUp(key);
@ -399,6 +471,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="key">The key to query.</param>
/// <returns>If the key is pressed and the press is not consumed yet.</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsPressedAvailable instead.")]
public bool IsKeyPressedAvailable(Keys key) {
return this.IsKeyPressed(key) && !this.IsPressConsumed(key);
}
@ -411,6 +484,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="key">The key to query.</param>
/// <returns>If the key is pressed and the press is not consumed yet.</returns>
[Obsolete("This method is deprecated. Use the GenericInput version TryConsumePressed instead.")]
public bool TryConsumeKeyPressed(Keys key) {
if (this.IsKeyPressedAvailable(key)) {
this.consumedPresses.Add((key, -1));
@ -419,24 +493,12 @@ namespace MLEM.Input {
return false;
}
/// <summary>
/// Returns whether the given modifier key is down.
/// </summary>
/// <param name="modifier">The modifier key</param>
/// <returns>If the modifier key is down</returns>
public bool IsModifierKeyDown(ModifierKey modifier) {
foreach (var key in modifier.GetKeys()) {
if (this.IsKeyDown(key))
return true;
}
return false;
}
/// <summary>
/// Returns whether the given mouse button is currently down.
/// </summary>
/// <param name="button">The button to query</param>
/// <returns>Whether or not the queried button is down</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsDown instead.")]
public bool IsMouseButtonDown(MouseButton button) {
return this.MouseState.GetState(button) == ButtonState.Pressed;
}
@ -446,6 +508,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="button">The button to query</param>
/// <returns>Whether or not the queried button is up</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsUp instead.")]
public bool IsMouseButtonUp(MouseButton button) {
return this.MouseState.GetState(button) == ButtonState.Released;
}
@ -455,6 +518,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="button">The button to query</param>
/// <returns>Whether or not the queried button was down</returns>
[Obsolete("This method is deprecated. Use the GenericInput version WasDown instead.")]
public bool WasMouseButtonDown(MouseButton button) {
return this.LastMouseState.GetState(button) == ButtonState.Pressed;
}
@ -464,6 +528,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="button">The button to query</param>
/// <returns>Whether or not the queried button was up</returns>
[Obsolete("This method is deprecated. Use the GenericInput version WasUp instead.")]
public bool WasMouseButtonUp(MouseButton button) {
return this.LastMouseState.GetState(button) == ButtonState.Released;
}
@ -474,6 +539,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="button">The button to query</param>
/// <returns>Whether the button is pressed</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsPressed instead.")]
public bool IsMouseButtonPressed(MouseButton button) {
if (this.InvertPressBehavior)
return this.WasMouseButtonDown(button) && this.IsMouseButtonUp(button);
@ -485,6 +551,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="button">The button to query.</param>
/// <returns>If the button is pressed and the press is not consumed yet.</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsPressedAvailable instead.")]
public bool IsMouseButtonPressedAvailable(MouseButton button) {
return this.IsMouseButtonPressed(button) && !this.IsPressConsumed(button);
}
@ -496,6 +563,7 @@ namespace MLEM.Input {
/// </summary>
/// <param name="button">The button to query.</param>
/// <returns>If the button is pressed and the press is not consumed yet.</returns>
[Obsolete("This method is deprecated. Use the GenericInput version TryConsumePressed instead.")]
public bool TryConsumeMouseButtonPressed(MouseButton button) {
if (this.IsMouseButtonPressedAvailable(button)) {
this.consumedPresses.Add((button, -1));
@ -505,6 +573,7 @@ namespace MLEM.Input {
}
/// <inheritdoc cref="GamePadState.IsButtonDown"/>
[Obsolete("This method is deprecated. Use the GenericInput version IsDown instead.")]
public bool IsGamepadButtonDown(Buttons button, int index = -1) {
if (index < 0) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
@ -517,6 +586,7 @@ namespace MLEM.Input {
}
/// <inheritdoc cref="GamePadState.IsButtonUp"/>
[Obsolete("This method is deprecated. Use the GenericInput version IsUp instead.")]
public bool IsGamepadButtonUp(Buttons button, int index = -1) {
if (index < 0) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
@ -529,6 +599,7 @@ namespace MLEM.Input {
}
/// <inheritdoc cref="GamePadState.IsButtonDown"/>
[Obsolete("This method is deprecated. Use the GenericInput version WasDown instead.")]
public bool WasGamepadButtonDown(Buttons button, int index = -1) {
if (index < 0) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
@ -541,6 +612,7 @@ namespace MLEM.Input {
}
/// <inheritdoc cref="GamePadState.IsButtonUp"/>
[Obsolete("This method is deprecated. Use the GenericInput version WasUp instead.")]
public bool WasGamepadButtonUp(Buttons button, int index = -1) {
if (index < 0) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
@ -560,6 +632,7 @@ namespace MLEM.Input {
/// <param name="button">The button to query</param>
/// <param name="index">The zero-based index of the gamepad, or -1 for any gamepad</param>
/// <returns>Whether the given button is pressed</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsPressed instead.")]
public bool IsGamepadButtonPressed(Buttons button, int index = -1) {
if (this.HandleGamepadRepeats) {
if (index < 0) {
@ -583,6 +656,7 @@ namespace MLEM.Input {
/// <param name="button">The button to query</param>
/// <param name="index">The zero-based index of the gamepad, or -1 for any gamepad</param>
/// <returns>Whether the given button is pressed</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsPressedIgnoreRepeats instead.")]
public bool IsGamepadButtonPressedIgnoreRepeats(Buttons button, int index = -1) {
if (this.InvertPressBehavior)
return this.WasGamepadButtonDown(button, index) && this.IsGamepadButtonUp(button, index);
@ -595,6 +669,7 @@ namespace MLEM.Input {
/// <param name="button">The button to query.</param>
/// <param name="index">The zero-based index of the gamepad, or -1 for any gamepad.</param>
/// <returns>Whether the given button is pressed and the press is not consumed yet.</returns>
[Obsolete("This method is deprecated. Use the GenericInput version IsPressedAvailable instead.")]
public bool IsGamepadButtonPressedAvailable(Buttons button, int index = -1) {
return this.IsGamepadButtonPressed(button) && !this.IsPressConsumed(button, index) && (index < 0 || !this.IsPressConsumed(button));
}
@ -608,6 +683,7 @@ namespace MLEM.Input {
/// <param name="button">The button to query.</param>
/// <param name="index">The zero-based index of the gamepad, or -1 for any gamepad.</param>
/// <returns>Whether the given button is pressed and the press is not consumed yet.</returns>
[Obsolete("This method is deprecated. Use the GenericInput version TryConsumePressed instead.")]
public bool TryConsumeGamepadButtonPressed(Buttons button, int index = -1) {
if (this.IsGamepadButtonPressedAvailable(button, index)) {
this.consumedPresses.Add((button, index));
@ -650,6 +726,19 @@ namespace MLEM.Input {
return false;
}
/// <summary>
/// Adds a gesture to the <see cref="Gestures"/> collection and allows it to be queried using <see cref="GetGesture"/> and <see cref="GetViewportGesture"/> for the duration of the update frame that it was added on.
/// This method should be used when <see cref="ExternalGestureHandling"/> is set to true, but <see cref="GetGesture"/> and <see cref="GetViewportGesture"/> should still be available.
/// For more info, see https://mlem.ellpeck.de/articles/input.html#external-gesture-handling.
/// </summary>
/// <param name="sample">The gesture sample to add.</param>
/// <exception cref="InvalidOperationException">Thrown if <see cref="ExternalGestureHandling"/> is false.</exception>
public void AddExternalGesture(GestureSample sample) {
if (!this.ExternalGestureHandling)
throw new InvalidOperationException($"Cannot add external gestures if {nameof(this.ExternalGestureHandling)} is false");
this.gestures.Add(sample);
}
/// <summary>
/// Returns if a given control of any kind is down.
/// This is a helper function that can be passed a <see cref="Keys"/>, <see cref="Buttons"/> or <see cref="MouseButton"/>.
@ -657,15 +746,21 @@ namespace MLEM.Input {
/// <param name="control">The control whose down state to query</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad</param>
/// <returns>Whether the given control is down</returns>
/// <exception cref="ArgumentException">If the passed control isn't of a supported type</exception>
public bool IsDown(GenericInput control, int index = -1) {
switch (control.Type) {
case GenericInput.InputType.Keyboard:
return this.IsKeyDown(control);
case GenericInput.InputType.Gamepad:
return this.IsGamepadButtonDown(control, index);
case GenericInput.InputType.Mouse:
return this.IsMouseButtonDown(control);
case InputType.Keyboard:
return this.KeyboardState.IsKeyDown(control);
case InputType.Gamepad:
if (index < 0) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
if (this.GetGamepadState(i).GetAnalogValue(control) > this.GamepadButtonDeadzone)
return true;
}
return false;
}
return this.GetGamepadState(index).GetAnalogValue(control) > this.GamepadButtonDeadzone;
case InputType.Mouse:
return this.MouseState.GetState(control) == ButtonState.Pressed;
default:
return false;
}
@ -678,15 +773,75 @@ namespace MLEM.Input {
/// <param name="control">The control whose up state to query</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad</param>
/// <returns>Whether the given control is up.</returns>
/// <exception cref="ArgumentException">If the passed control isn't of a supported type</exception>
public bool IsUp(GenericInput control, int index = -1) {
switch (control.Type) {
case GenericInput.InputType.Keyboard:
return this.IsKeyUp(control);
case GenericInput.InputType.Gamepad:
return this.IsGamepadButtonUp(control, index);
case GenericInput.InputType.Mouse:
return this.IsMouseButtonUp(control);
case InputType.Keyboard:
return this.KeyboardState.IsKeyUp(control);
case InputType.Gamepad:
if (index < 0) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
if (this.GetGamepadState(i).GetAnalogValue(control) <= this.GamepadButtonDeadzone)
return true;
}
return false;
}
return this.GetGamepadState(index).GetAnalogValue(control) <= this.GamepadButtonDeadzone;
case InputType.Mouse:
return this.MouseState.GetState(control) == ButtonState.Released;
default:
return true;
}
}
/// <summary>
/// Returns if a given control of any kind was down in the last update call.
/// This is a helper function that can be passed a <see cref="Keys"/>, <see cref="Buttons"/> or <see cref="MouseButton"/>.
/// </summary>
/// <param name="control">The control whose down state to query</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad</param>
/// <returns>Whether the given control was down</returns>
public bool WasDown(GenericInput control, int index = -1) {
switch (control.Type) {
case InputType.Keyboard:
return this.LastKeyboardState.IsKeyDown(control);
case InputType.Gamepad:
if (index < 0) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
if (this.GetLastGamepadState(i).GetAnalogValue(control) > this.GamepadButtonDeadzone)
return true;
}
return false;
}
return this.GetLastGamepadState(index).GetAnalogValue(control) > this.GamepadButtonDeadzone;
case InputType.Mouse:
return this.LastMouseState.GetState(control) == ButtonState.Pressed;
default:
return false;
}
}
/// <summary>
/// Returns if a given control of any kind was up in the last update call.
/// This is a helper function that can be passed a <see cref="Keys"/>, <see cref="Buttons"/> or <see cref="MouseButton"/>.
/// </summary>
/// <param name="control">The control whose up state to query</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad</param>
/// <returns>Whether the given control was up.</returns>
public bool WasUp(GenericInput control, int index = -1) {
switch (control.Type) {
case InputType.Keyboard:
return this.LastKeyboardState.IsKeyUp(control);
case InputType.Gamepad:
if (index < 0) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
if (this.GetLastGamepadState(i).GetAnalogValue(control) <= this.GamepadButtonDeadzone)
return true;
}
return false;
}
return this.GetLastGamepadState(index).GetAnalogValue(control) <= this.GamepadButtonDeadzone;
case InputType.Mouse:
return this.LastMouseState.GetState(control) == ButtonState.Released;
default:
return true;
}
@ -694,23 +849,47 @@ namespace MLEM.Input {
/// <summary>
/// Returns if a given control of any kind is pressed.
/// This is a helper function that can be passed a <see cref="Keys"/>, <see cref="Buttons"/> or <see cref="MouseButton"/>.
/// If <see cref="HandleKeyboardRepeats"/> or <see cref="HandleGamepadRepeats"/> are true, this method will also return true to signify a key or gamepad button repeat.
/// An input is considered pressed if it was not down the last update call, but is down the current update call. If <see cref="InvertPressBehavior"/> is true, this behavior is inverted.
/// </summary>
/// <param name="control">The control whose pressed state to query</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad</param>
/// <returns>Whether the given control is pressed.</returns>
/// <exception cref="ArgumentException">If the passed control isn't of a supported type</exception>
public bool IsPressed(GenericInput control, int index = -1) {
// handle repeat events for specific inputs, and delegate to default "ignore repeats" behavior otherwise
switch (control.Type) {
case GenericInput.InputType.Keyboard:
return this.IsKeyPressed(control);
case GenericInput.InputType.Gamepad:
return this.IsGamepadButtonPressed(control, index);
case GenericInput.InputType.Mouse:
return this.IsMouseButtonPressed(control);
default:
return false;
case InputType.Keyboard:
if (this.HandleKeyboardRepeats && (Keys) control == this.heldKey && this.triggerKeyRepeat)
return true;
break;
case InputType.Gamepad:
if (this.HandleGamepadRepeats) {
if (index < 0) {
for (var i = 0; i < this.ConnectedGamepads; i++) {
if (this.heldGamepadButtons[i] == (Buttons) control && this.triggerGamepadButtonRepeat[i])
return true;
}
} else if (this.heldGamepadButtons[index] == (Buttons) control && this.triggerGamepadButtonRepeat[index]) {
return true;
}
}
break;
}
return this.IsPressedIgnoreRepeats(control, index);
}
/// <summary>
/// An input is considered pressed if it was not down the last update call, but is down the current update call. If <see cref="InvertPressBehavior"/> is true, this behavior is inverted.
/// This has the same behavior as <see cref="IsPressed"/>, but ignores keyboard and gamepad repeat events.
/// If <see cref="HandleKeyboardRepeats"/> and <see cref="HandleGamepadRepeats"/> are false, this method does the same as <see cref="IsPressed"/>.
/// </summary>
/// <param name="control">The control whose pressed state to query</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad</param>
/// <returns>Whether the given control is pressed, ignoring repeat events.</returns>
public bool IsPressedIgnoreRepeats(GenericInput control, int index = -1) {
if (this.InvertPressBehavior)
return this.WasDown(control, index) && this.IsUp(control, index);
return this.WasUp(control, index) && this.IsDown(control, index);
}
/// <summary>
@ -720,16 +899,7 @@ namespace MLEM.Input {
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
/// <returns>Whether the given control is pressed and the press is not consumed yet.</returns>
public bool IsPressedAvailable(GenericInput control, int index = -1) {
switch (control.Type) {
case GenericInput.InputType.Keyboard:
return this.IsKeyPressedAvailable(control);
case GenericInput.InputType.Gamepad:
return this.IsGamepadButtonPressedAvailable(control, index);
case GenericInput.InputType.Mouse:
return this.IsMouseButtonPressedAvailable(control);
default:
return false;
}
return this.IsPressed(control, index) && !this.IsPressConsumed(control, index);
}
/// <summary>
@ -740,16 +910,48 @@ namespace MLEM.Input {
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
/// <returns>Whether the given control is pressed and the press is not consumed yet.</returns>
public bool TryConsumePressed(GenericInput control, int index = -1) {
switch (control.Type) {
case GenericInput.InputType.Keyboard:
return this.TryConsumeKeyPressed(control);
case GenericInput.InputType.Gamepad:
return this.TryConsumeGamepadButtonPressed(control, index);
case GenericInput.InputType.Mouse:
return this.TryConsumeMouseButtonPressed(control);
default:
if (this.IsPressedAvailable(control, index)) {
this.consumedPresses.Add((control, index));
return true;
}
return false;
}
/// <summary>
/// Returns if a given control of any kind was just let go this frame, and had been down for less than the given <paramref name="time"/> before that. Essentially, this action signifies a short press action.
/// </summary>
/// <param name="control">The control whose pressed state to query.</param>
/// <param name="time">The maximum time that the control should have been down for.</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
/// <returns>Whether the given control was pressed for less than the given time.</returns>
public bool WasPressedForLess(GenericInput control, TimeSpan time, int index = -1) {
return this.WasDown(control, index) && this.IsUp(control, index) && this.GetDownTime(control, index) < time;
}
/// <summary>
/// Returns if a given control of any kind was just let go this frame, and had been down for less than the given <paramref name="time"/> before that, and if the press has not been consumed yet using <see cref="TryConsumePressedForLess"/>. Essentially, this action signifies a short press action.
/// </summary>
/// <param name="control">The control whose pressed state to query.</param>
/// <param name="time">The maximum time that the control should have been down for.</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
/// <returns>Whether the given control was pressed for less than the given time, and the press has not been consumed yet.</returns>
public bool WasPressedForLessAvailable(GenericInput control, TimeSpan time, int index = -1) {
return this.WasPressedForLess(control, time, index) && !this.IsPressConsumed(control, index);
}
/// <summary>
/// Returns if a given control of any kind was just let go this frame, and had been down for less than the given <paramref name="time"/> before that, and if the press has not been consumed yet using <see cref="TryConsumePressedForLess"/>, and marks the press as consumed if it is. Essentially, this action signifies a short press action.
/// </summary>
/// <param name="control">The control whose pressed state to query.</param>
/// <param name="time">The maximum time that the control should have been down for.</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
/// <returns>Whether the given control was pressed for less than the given time, and the press was successfully consumed.</returns>
public bool TryConsumePressedForLess(GenericInput control, TimeSpan time, int index = -1) {
if (this.WasPressedForLessAvailable(control, time, index)) {
this.consumedPresses.Add((control, index));
return true;
}
return false;
}
/// <inheritdoc cref="IsDown"/>
@ -790,23 +992,26 @@ namespace MLEM.Input {
/// <summary>
/// Tries to retrieve the amount of time that a given <see cref="GenericInput"/> has been held down for.
/// If the input is currently down, this method returns true and the amount of time that it has been down for is stored in <paramref name="downTime"/>.
/// If the input is currently down or has been down previously, this method returns true and the amount of time that it has currently or last been down for is stored in <paramref name="downTime"/>.
/// </summary>
/// <param name="input">The input whose down time to query.</param>
/// <param name="downTime">The resulting down time, or <see cref="TimeSpan.Zero"/> if the input is not being held.</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
/// <returns>Whether the input is currently being held.</returns>
public bool TryGetDownTime(GenericInput input, out TimeSpan downTime, int index = -1) {
if (this.inputsDown.TryGetValue((input, index), out var start)) {
downTime = DateTime.UtcNow - start;
if (this.inputDownTimes.TryGetValue((input, index), out var wentDown)) {
// if we're currently down, we return the amount of time we've been down for so far
// if we're not currently down, we return the last amount of time we were down for
downTime = (this.IsDown(input) || !this.inputUpTimes.TryGetValue((input, index), out var wentUp) ? DateTime.UtcNow : wentUp) - wentDown;
return true;
}
downTime = default;
return false;
}
/// <summary>
/// Returns the amount of time that a given <see cref="GenericInput"/> has been held down for.
/// If this input isn't currently down, this method returns <see cref="TimeSpan.Zero"/>.
/// Returns the current or last amount of time that a given <see cref="GenericInput"/> has been held down for.
/// If this input isn't currently down and has not been down previously, this method returns <see cref="TimeSpan.Zero"/>.
/// </summary>
/// <param name="input">The input whose down time to query.</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
@ -818,23 +1023,26 @@ namespace MLEM.Input {
/// <summary>
/// Tries to retrieve the amount of time that a given <see cref="GenericInput"/> has been up for since the last time it was down.
/// If the input is currently up, this method returns true and the amount of time that it has been up for is stored in <paramref name="upTime"/>.
/// If the input has previously been down, this method returns true and the amount of time that it has been up for is stored in <paramref name="upTime"/>.
/// </summary>
/// <param name="input">The input whose up time to query.</param>
/// <param name="upTime">The resulting up time, or <see cref="TimeSpan.Zero"/> if the input is being held.</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
/// <returns>Whether the input is currently up.</returns>
public bool TryGetUpTime(GenericInput input, out TimeSpan upTime, int index = -1) {
if (this.inputUpTimes.TryGetValue((input, index), out var start)) {
upTime = DateTime.UtcNow - start;
if (this.inputUpTimes.TryGetValue((input, index), out var wentUp)) {
// if we're currently up, we return the amount of time we've been up for so far
// if we're not currently up, we return the last amount of time we were up for
upTime = (this.IsUp(input) || !this.inputDownTimes.TryGetValue((input, index), out var wentDown) ? DateTime.UtcNow : wentDown) - wentUp;
return true;
}
upTime = default;
return false;
}
/// <summary>
/// Returns the amount of time that a given <see cref="GenericInput"/> has been up for since the last time it was down.
/// If this input isn't currently up, this method returns <see cref="TimeSpan.Zero"/>.
/// Returns the amount of time that a given <see cref="GenericInput"/> has last been up for since the last time it was down.
/// If this input hasn't been down previously, this method returns <see cref="TimeSpan.Zero"/>.
/// </summary>
/// <param name="input">The input whose up time to query.</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>
@ -857,12 +1065,13 @@ namespace MLEM.Input {
lastPressTime = DateTime.UtcNow - start;
return true;
}
lastPressTime = default;
return false;
}
/// <summary>
/// Returns the amount of time that has passed since a given <see cref="GenericInput"/> last counted as pressed.
/// If this input hasn't been pressed previously, or is currently pressed, this method returns <see cref="TimeSpan.Zero"/>.
/// If this input hasn't been pressed previously, this method returns <see cref="TimeSpan.Zero"/>.
/// </summary>
/// <param name="input">The input whose up time to query.</param>
/// <param name="index">The index of the gamepad to query (if applicable), or -1 for any gamepad.</param>

View file

@ -3,6 +3,10 @@ using System.Collections.Generic;
using System.Linq;
using System.Runtime.Serialization;
#if NET452
using MLEM.Extensions;
#endif
namespace MLEM.Input {
/// <summary>
/// A keybind represents a generic way to trigger input.
@ -13,8 +17,10 @@ namespace MLEM.Input {
[DataContract]
public class Keybind : IComparable<Keybind>, IComparable {
private static readonly Combination[] EmptyCombinations = new Combination[0];
[DataMember]
private Combination[] combinations = Array.Empty<Combination>();
private Combination[] combinations = Keybind.EmptyCombinations;
/// <summary>
/// Creates a new keybind and adds the given key and modifiers using <see cref="Add(MLEM.Input.GenericInput,MLEM.Input.GenericInput[])"/>
@ -77,7 +83,7 @@ namespace MLEM.Input {
/// </summary>
/// <returns>This keybind, for chaining</returns>
public Keybind Clear() {
this.combinations = Array.Empty<Combination>();
this.combinations = Keybind.EmptyCombinations;
return this;
}
@ -117,6 +123,21 @@ namespace MLEM.Input {
return false;
}
/// <summary>
/// Returns whether this keybind was considered to be down in the last update call.
/// See <see cref="InputHandler.WasDown"/> for more information.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>Whether this keybind was considered to be down</returns>
public bool WasDown(InputHandler handler, int gamepadIndex = -1) {
foreach (var combination in this.combinations) {
if (combination.WasDown(handler, gamepadIndex))
return true;
}
return false;
}
/// <summary>
/// Returns whether this keybind is considered to be pressed.
/// See <see cref="InputHandler.IsPressed"/> for more information.
@ -177,6 +198,54 @@ namespace MLEM.Input {
return false;
}
/// <summary>
/// Returns whether any of this keybind's modifier keys were down in the last update call.
/// See <see cref="InputHandler.WasDown"/> for more information.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>Whether any of this keyboard's modifier keys were down</returns>
public bool WasModifierDown(InputHandler handler, int gamepadIndex = -1) {
foreach (var combination in this.combinations) {
if (combination.WasModifierDown(handler, gamepadIndex))
return true;
}
return false;
}
/// <summary>
/// Returns the amount of time that this keybind has been held down for.
/// If this input isn't currently down, this method returns <see cref="TimeSpan.Zero"/>.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>The resulting down time, or <see cref="TimeSpan.Zero"/> if the input is not being held.</returns>
public TimeSpan GetDownTime(InputHandler handler, int gamepadIndex = -1) {
return this.combinations.Max(c => c.GetDownTime(handler, gamepadIndex));
}
/// <summary>
/// Returns the amount of time that this keybind has been up for since the last time it was down.
/// If this input isn't currently up, this method returns <see cref="TimeSpan.Zero"/>.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>The resulting up time, or <see cref="TimeSpan.Zero"/> if the input is being held.</returns>
public TimeSpan GetUpTime(InputHandler handler, int gamepadIndex = -1) {
return this.combinations.Min(c => c.GetUpTime(handler, gamepadIndex));
}
/// <summary>
/// Returns the amount of time that has passed since this keybind last counted as pressed.
/// If this input hasn't been pressed previously, or is currently pressed, this method returns <see cref="TimeSpan.Zero"/>.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>The resulting up time, or <see cref="TimeSpan.Zero"/> if the input has never been pressed, or is currently pressed.</returns>
public TimeSpan GetTimeSincePress(InputHandler handler, int gamepadIndex = -1) {
return this.combinations.Min(c => c.GetTimeSincePress(handler, gamepadIndex));
}
/// <summary>
/// Returns an enumerable of all of the combinations that this keybind currently contains
/// </summary>
@ -281,7 +350,7 @@ namespace MLEM.Input {
}
/// <summary>
/// Returns whether this combination is currently down
/// Returns whether this combination is currently down.
/// See <see cref="InputHandler.IsDown"/> for more information.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
@ -291,6 +360,17 @@ namespace MLEM.Input {
return this.IsModifierDown(handler, gamepadIndex) && handler.IsDown(this.Key, gamepadIndex);
}
/// <summary>
/// Returns whether this combination was down in the last upate call.
/// See <see cref="InputHandler.WasDown"/> for more information.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>Whether this combination was down</returns>
public bool WasDown(InputHandler handler, int gamepadIndex = -1) {
return this.WasModifierDown(handler, gamepadIndex) && handler.WasDown(this.Key, gamepadIndex);
}
/// <summary>
/// Returns whether this combination is currently pressed.
/// See <see cref="InputHandler.IsPressed"/> for more information.
@ -326,20 +406,65 @@ namespace MLEM.Input {
}
/// <summary>
/// Returns whether this combination's modifier keys are currently down
/// Returns whether all of this combination's modifier keys are currently down.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>Whether this combination's modifiers are down</returns>
public bool IsModifierDown(InputHandler handler, int gamepadIndex = -1) {
if (this.Modifiers.Length <= 0)
return true;
foreach (var modifier in this.Modifiers) {
if (handler.IsDown(modifier, gamepadIndex))
if (!handler.IsDown(modifier, gamepadIndex))
return false;
}
return true;
}
/// <summary>
/// Returns whether all of this combination's modifier keys were down in the last update call.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>Whether this combination's modifiers were down</returns>
public bool WasModifierDown(InputHandler handler, int gamepadIndex = -1) {
foreach (var modifier in this.Modifiers) {
if (!handler.WasDown(modifier, gamepadIndex))
return false;
}
return true;
}
/// <summary>
/// Returns the amount of time that this combination has been held down for.
/// If this input isn't currently down, this method returns <see cref="TimeSpan.Zero"/>.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>The resulting down time, or <see cref="TimeSpan.Zero"/> if the input is not being held.</returns>
public TimeSpan GetDownTime(InputHandler handler, int gamepadIndex = -1) {
return handler.GetDownTime(this.Key, gamepadIndex);
}
/// <summary>
/// Returns the amount of time that this combination has been up for since the last time it was down.
/// If this input isn't currently up, this method returns <see cref="TimeSpan.Zero"/>.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>The resulting up time, or <see cref="TimeSpan.Zero"/> if the input is being held.</returns>
public TimeSpan GetUpTime(InputHandler handler, int gamepadIndex = -1) {
return handler.GetUpTime(this.Key, gamepadIndex);
}
/// <summary>
/// Returns the amount of time that has passed since this combination last counted as pressed.
/// If this input hasn't been pressed previously, or is currently pressed, this method returns <see cref="TimeSpan.Zero"/>.
/// </summary>
/// <param name="handler">The input handler to query the keys with</param>
/// <param name="gamepadIndex">The index of the gamepad to query, or -1 to query all gamepads</param>
/// <returns>The resulting up time, or <see cref="TimeSpan.Zero"/> if the input has never been pressed, or is currently pressed.</returns>
public TimeSpan GetTimeSincePress(InputHandler handler, int gamepadIndex = -1) {
return handler.GetTimeSincePress(this.Key, gamepadIndex);
}
/// <summary>
/// Converts this combination into an easily human-readable string.

View file

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework.Input;
using MLEM.Misc;
namespace MLEM.Input {
/// <summary>
@ -12,7 +12,7 @@ namespace MLEM.Input {
/// <summary>
/// All enum values of <see cref="ModifierKey"/>
/// </summary>
public static readonly ModifierKey[] ModifierKeys = EnumHelper.GetValues<ModifierKey>().ToArray();
public static readonly ModifierKey[] ModifierKeys = {ModifierKey.None, ModifierKey.Shift, ModifierKey.Control, ModifierKey.Alt};
private static readonly Dictionary<ModifierKey, Keys[]> KeysLookup = new Dictionary<ModifierKey, Keys[]> {
{ModifierKey.Shift, new[] {Keys.LeftShift, Keys.RightShift}},
{ModifierKey.Control, new[] {Keys.LeftControl, Keys.RightControl}},

View file

@ -1,7 +1,5 @@
using System;
using System.Linq;
using Microsoft.Xna.Framework.Input;
using MLEM.Misc;
namespace MLEM.Input {
/// <summary>
@ -12,7 +10,7 @@ namespace MLEM.Input {
/// <summary>
/// All enum values of <see cref="MouseButton"/>
/// </summary>
public static readonly MouseButton[] MouseButtons = EnumHelper.GetValues<MouseButton>().ToArray();
public static readonly MouseButton[] MouseButtons = {MouseButton.Left, MouseButton.Middle, MouseButton.Right, MouseButton.Extra1, MouseButton.Extra2};
/// <summary>
/// Returns the <see cref="ButtonState"/> of the given mouse button.

View file

@ -246,7 +246,7 @@ namespace MLEM.Input {
/// <returns>Whether text was successfully input.</returns>
public bool OnTextInput(Keys key, char character) {
// FNA's text input event doesn't supply keys, so we handle this in Update
#if !FNA
#if !FNA
if (key == Keys.Back) {
if (this.CaretPos > 0) {
this.CaretPos--;
@ -261,9 +261,9 @@ namespace MLEM.Input {
return this.InsertText(character);
}
return false;
#else
#else
return this.InsertText(character);
#endif
#endif
}
/// <summary>
@ -274,8 +274,8 @@ namespace MLEM.Input {
public void Update(GameTime time, InputHandler input) {
this.UpdateTextDataIfDirty();
#if FNA
// FNA's text input event doesn't supply keys, so we handle this here
#if FNA
if (this.CaretPos > 0 && input.TryConsumePressed(Keys.Back)) {
this.CaretPos--;
this.RemoveText(this.CaretPos, 1);
@ -284,21 +284,21 @@ namespace MLEM.Input {
} else if (this.Multiline && input.TryConsumePressed(Keys.Enter)) {
this.InsertText('\n');
} else
#endif
#endif
if (this.CaretPos > 0 && input.TryConsumePressed(Keys.Left)) {
this.CaretPos--;
} else if (this.CaretPos < this.text.Length && input.TryConsumePressed(Keys.Right)) {
this.CaretPos++;
} else if (this.Multiline && input.IsKeyPressedAvailable(Keys.Up) && this.MoveCaretToLine(this.CaretLine - 1)) {
} else if (this.Multiline && input.IsPressedAvailable(Keys.Up) && this.MoveCaretToLine(this.CaretLine - 1)) {
input.TryConsumePressed(Keys.Up);
} else if (this.Multiline && input.IsKeyPressedAvailable(Keys.Down) && this.MoveCaretToLine(this.CaretLine + 1)) {
} else if (this.Multiline && input.IsPressedAvailable(Keys.Down) && this.MoveCaretToLine(this.CaretLine + 1)) {
input.TryConsumePressed(Keys.Down);
} else if (this.CaretPos != 0 && input.TryConsumePressed(Keys.Home)) {
this.CaretPos = 0;
} else if (this.CaretPos != this.text.Length && input.TryConsumePressed(Keys.End)) {
this.CaretPos = this.text.Length;
} else if (input.IsModifierKeyDown(ModifierKey.Control)) {
if (input.IsKeyPressedAvailable(Keys.V)) {
if (input.IsPressedAvailable(Keys.V)) {
var clip = this.PasteFromClipboardFunction?.Invoke();
if (clip != null) {
this.InsertText(clip, true);
@ -417,9 +417,10 @@ namespace MLEM.Input {
private bool FilterText(ref string text, bool removeMismatching) {
if (removeMismatching) {
var result = new StringBuilder();
foreach (var c in text) {
if (this.InputRule(this, c.ToCachedString()))
result.Append(c);
foreach (var codePoint in new CodePointSource(text)) {
var character = char.ConvertFromUtf32(codePoint);
if (this.InputRule(this, character))
result.Append(character);
}
text = result.ToString();
} else if (!this.InputRule(this, text))

View file

@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net452;netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
<RootNamespace>MLEM</RootNamespace>
<DefineConstants>$(DefineConstants);FNA</DefineConstants>
</PropertyGroup>
@ -20,9 +21,11 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FNA\FNA.Core.csproj">
<ProjectReference Include="..\FNA\FNA.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
<PackageReference Include="System.ValueTuple" Version="4.5.0" Condition="'$(TargetFramework)'=='net452'" />
</ItemGroup>
<ItemGroup>

View file

@ -1,8 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFrameworks>net452;netstandard2.0;net6.0</TargetFrameworks>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<ProduceReferenceAssembly>true</ProduceReferenceAssembly>
<IsTrimmable>true</IsTrimmable>
</PropertyGroup>
<PropertyGroup>
@ -21,6 +22,8 @@
<PackageReference Include="MonoGame.Framework.DesktopGL" Version="3.8.0.1641">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="System.ValueTuple" Version="4.5.0" Condition="'$(TargetFramework)'=='net452'" />
</ItemGroup>
<ItemGroup>

View file

@ -71,7 +71,10 @@ namespace MLEM.Misc {
/// <summary>
/// All <see cref="Direction2"/> enum values
/// </summary>
public static readonly Direction2[] All = EnumHelper.GetValues<Direction2>().ToArray();
public static readonly Direction2[] All = {
Direction2.None, Direction2.Up, Direction2.Right, Direction2.Down, Direction2.Left,
Direction2.UpRight, Direction2.DownRight, Direction2.UpLeft, Direction2.DownLeft
};
/// <summary>
/// The <see cref="Direction2.Up"/> through <see cref="Direction2.Left"/> directions
/// </summary>

View file

@ -1,30 +1,71 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Xna.Framework.Input;
namespace MLEM.Misc {
/// <summary>
/// A helper class that allows easier usage of <see cref="Enum"/> values.
/// </summary>
[Obsolete("EnumHelper has been moved into the DynamicEnums library: https://www.nuget.org/packages/DynamicEnums")]
public static class EnumHelper {
/// <summary>
/// All values of the <see cref="Buttons"/> enum.
/// </summary>
public static readonly Buttons[] Buttons = EnumHelper.GetValues<Buttons>().ToArray();
[Obsolete("This field has been moved to InputHandler.AllButtons")]
public static readonly Buttons[] Buttons = EnumHelper.GetValues<Buttons>();
/// <summary>
/// All values of the <see cref="Keys"/> enum.
/// </summary>
public static readonly Keys[] Keys = EnumHelper.GetValues<Keys>().ToArray();
[Obsolete("This field has been moved to InputHandler.AllKeys")]
public static readonly Keys[] Keys = EnumHelper.GetValues<Keys>();
/// <summary>
/// Returns all of the values of the given enum type.
/// Returns an array containing all of the values of the given enum type.
/// Note that this method is a version-independent equivalent of .NET 5's <c>Enum.GetValues&lt;TEnum&gt;</c>.
/// </summary>
/// <typeparam name="T">The type whose enum to get</typeparam>
/// <returns>An enumerable of the values of the enum, in declaration order.</returns>
public static IEnumerable<T> GetValues<T>() {
return Enum.GetValues(typeof(T)).Cast<T>();
public static T[] GetValues<T>() where T : struct, Enum {
#if NET6_0_OR_GREATER
return Enum.GetValues<T>();
#else
return (T[]) Enum.GetValues(typeof(T));
#endif
}
/// <summary>
/// Returns all of the defined values from the given enum type <typeparamref name="T"/> which are contained in <paramref name="combinedFlag"/>.
/// Note that, if combined flags are defined in <typeparamref name="T"/>, and <paramref name="combinedFlag"/> contains them, they will also be returned.
/// </summary>
/// <param name="combinedFlag">The combined flags whose individual flags to return.</param>
/// <param name="includeZero">Whether the enum value 0 should also be returned, if <typeparamref name="T"/> contains one.</param>
/// <typeparam name="T">The type of enum.</typeparam>
/// <returns>All of the flags that make up <paramref name="combinedFlag"/>.</returns>
public static IEnumerable<T> GetFlags<T>(T combinedFlag, bool includeZero = true) where T : struct, Enum {
foreach (var flag in EnumHelper.GetValues<T>()) {
if (combinedFlag.HasFlag(flag) && (includeZero || Convert.ToInt64(flag) != 0))
yield return flag;
}
}
/// <summary>
/// Returns all of the defined unique flags from the given enum type <typeparamref name="T"/> which are contained in <paramref name="combinedFlag"/>.
/// Any combined flags (flags that aren't powers of two) which are defined in <typeparamref name="T"/> will not be returned.
/// </summary>
/// <param name="combinedFlag">The combined flags whose individual flags to return.</param>
/// <typeparam name="T">The type of enum.</typeparam>
/// <returns>All of the unique flags that make up <paramref name="combinedFlag"/>.</returns>
public static IEnumerable<T> GetUniqueFlags<T>(T combinedFlag) where T : struct, Enum {
var uniqueFlag = 1;
foreach (var flag in EnumHelper.GetValues<T>()) {
var flagValue = Convert.ToInt64(flag);
// GetValues is always ordered by binary value, so we can be sure that the next flag is bigger than the last
while (uniqueFlag < flagValue)
uniqueFlag <<= 1;
if (flagValue == uniqueFlag && combinedFlag.HasFlag(flag))
yield return flag;
}
}
}

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Runtime.Serialization;
namespace MLEM.Misc {
/// <summary>
@ -8,15 +7,20 @@ namespace MLEM.Misc {
/// A lot of MLEM components extend this class to allow for users to add additional data to them easily.
/// This <see cref="IGenericDataHolder"/> implemention uses an underlying <see cref="Dictionary{String,Object}"/> that only keeps track of non-default values.
/// </summary>
[DataContract]
public class GenericDataHolder : IGenericDataHolder {
[DataMember(EmitDefaultValue = false)]
private static readonly string[] EmptyStrings = new string[0];
private Dictionary<string, object> data;
/// <inheritdoc />
[Obsolete("This method will be removed in a future update in favor of the generic SetData<T>.")]
public void SetData(string key, object data) {
if (data == default) {
this.SetData<object>(key, data);
}
/// <inheritdoc />
public void SetData<T>(string key, T data) {
if (EqualityComparer<T>.Default.Equals(data, default)) {
if (this.data != null)
this.data.Remove(key);
} else {
@ -34,9 +38,9 @@ namespace MLEM.Misc {
}
/// <inheritdoc />
public IReadOnlyCollection<string> GetDataKeys() {
public IEnumerable<string> GetDataKeys() {
if (this.data == null)
return Array.Empty<string>();
return GenericDataHolder.EmptyStrings;
return this.data.Keys;
}
@ -53,8 +57,16 @@ namespace MLEM.Misc {
/// </summary>
/// <param name="key">The key to store the data by</param>
/// <param name="data">The data to store in the object</param>
[Obsolete("This method will be removed in a future update in favor of the generic SetData<T>.")]
void SetData(string key, object data);
/// <summary>
/// Store a piece of generic data on this object.
/// </summary>
/// <param name="key">The key to store the data by</param>
/// <param name="data">The data to store in the object</param>
void SetData<T>(string key, T data);
/// <summary>
/// Returns a piece of generic data of the given type on this object.
/// </summary>
@ -67,7 +79,7 @@ namespace MLEM.Misc {
/// Returns all of the generic data that this object stores.
/// </summary>
/// <returns>The generic data on this object</returns>
IReadOnlyCollection<string> GetDataKeys();
IEnumerable<string> GetDataKeys();
}
}

View file

@ -95,6 +95,13 @@ namespace MLEM.Misc {
return new Padding(p.Left * scale, p.Right * scale, p.Top * scale, p.Bottom * scale);
}
/// <summary>
/// Scales a padding by a <see cref="Vector2"/>.
/// </summary>
public static Padding operator *(Padding p, Vector2 scale) {
return new Padding(p.Left * scale.X, p.Right * scale.X, p.Top * scale.Y, p.Bottom * scale.Y);
}
/// <summary>
/// Adds two paddings together in a memberwise fashion.
/// </summary>

Some files were not shown because too many files have changed in this diff Show more