1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-12 17:43:05 +08:00

Merge pull request #11461 from peppy/realm-key-binding-store

Add initial realm database implementation with KeyBindingStore migration
This commit is contained in:
Dean Herbert 2021-06-19 02:49:41 +09:00 committed by GitHub
commit 3f336d88ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 778 additions and 204 deletions

View File

@ -0,0 +1,101 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Input.Bindings;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using Realms;
namespace osu.Game.Tests.Database
{
[TestFixture]
public class TestRealmKeyBindingStore
{
private NativeStorage storage;
private RealmKeyBindingStore keyBindingStore;
private RealmContextFactory realmContextFactory;
[SetUp]
public void SetUp()
{
var directory = Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()));
storage = new NativeStorage(directory.FullName);
realmContextFactory = new RealmContextFactory(storage);
keyBindingStore = new RealmKeyBindingStore(realmContextFactory);
}
[Test]
public void TestDefaultsPopulationAndQuery()
{
Assert.That(query().Count, Is.EqualTo(0));
KeyBindingContainer testContainer = new TestKeyBindingContainer();
keyBindingStore.Register(testContainer);
Assert.That(query().Count, Is.EqualTo(3));
Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Back).Count, Is.EqualTo(1));
Assert.That(query().Where(k => k.ActionInt == (int)GlobalAction.Select).Count, Is.EqualTo(2));
}
private IQueryable<RealmKeyBinding> query() => realmContextFactory.Context.All<RealmKeyBinding>();
[Test]
public void TestUpdateViaQueriedReference()
{
KeyBindingContainer testContainer = new TestKeyBindingContainer();
keyBindingStore.Register(testContainer);
var backBinding = query().Single(k => k.ActionInt == (int)GlobalAction.Back);
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.Escape }));
var tsr = ThreadSafeReference.Create(backBinding);
using (var usage = realmContextFactory.GetForWrite())
{
var binding = usage.Realm.ResolveReference(tsr);
binding.KeyCombination = new KeyCombination(InputKey.BackSpace);
usage.Commit();
}
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
// check still correct after re-query.
backBinding = query().Single(k => k.ActionInt == (int)GlobalAction.Back);
Assert.That(backBinding.KeyCombination.Keys, Is.EquivalentTo(new[] { InputKey.BackSpace }));
}
[TearDown]
public void TearDown()
{
realmContextFactory.Dispose();
storage.DeleteDirectory(string.Empty);
}
public class TestKeyBindingContainer : KeyBindingContainer
{
public override IEnumerable<IKeyBinding> DefaultKeyBindings =>
new[]
{
new KeyBinding(InputKey.Escape, GlobalAction.Back),
new KeyBinding(InputKey.Enter, GlobalAction.Select),
new KeyBinding(InputKey.Space, GlobalAction.Select),
};
}
}
}

View File

@ -142,7 +142,10 @@ namespace osu.Game.Tests.NonVisual
foreach (var file in osuStorage.IgnoreFiles)
{
Assert.That(File.Exists(Path.Combine(originalDirectory, file)));
// avoid touching realm files which may be a pipe and break everything.
// this is also done locally inside OsuStorage via the IgnoreFiles list.
if (file.EndsWith(".ini", StringComparison.Ordinal))
Assert.That(File.Exists(Path.Combine(originalDirectory, file)));
Assert.That(storage.Exists(file), Is.False);
}

View File

@ -74,7 +74,6 @@ namespace osu.Game.Tests.Visual
typeof(FileStore),
typeof(ScoreManager),
typeof(BeatmapManager),
typeof(KeyBindingStore),
typeof(SettingsStore),
typeof(RulesetConfigCache),
typeof(OsuColour),

View File

@ -0,0 +1,16 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using Newtonsoft.Json;
using Realms;
namespace osu.Game.Database
{
public interface IHasGuidPrimaryKey
{
[JsonIgnore]
[PrimaryKey]
Guid ID { get; set; }
}
}

View File

@ -0,0 +1,27 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using Realms;
namespace osu.Game.Database
{
public interface IRealmFactory
{
/// <summary>
/// The main realm context, bound to the update thread.
/// </summary>
Realm Context { get; }
/// <summary>
/// Get a fresh context for read usage.
/// </summary>
RealmContextFactory.RealmUsage GetForRead();
/// <summary>
/// Request a context for write usage.
/// This method may block if a write is already active on a different thread.
/// </summary>
/// <returns>A usage containing a usable context.</returns>
RealmContextFactory.RealmWriteUsage GetForWrite();
}
}

View File

@ -24,13 +24,15 @@ namespace osu.Game.Database
public DbSet<BeatmapDifficulty> BeatmapDifficulty { get; set; }
public DbSet<BeatmapMetadata> BeatmapMetadata { get; set; }
public DbSet<BeatmapSetInfo> BeatmapSetInfo { get; set; }
public DbSet<DatabasedKeyBinding> DatabasedKeyBinding { get; set; }
public DbSet<DatabasedSetting> DatabasedSetting { get; set; }
public DbSet<FileInfo> FileInfo { get; set; }
public DbSet<RulesetInfo> RulesetInfo { get; set; }
public DbSet<SkinInfo> SkinInfo { get; set; }
public DbSet<ScoreInfo> ScoreInfo { get; set; }
// migrated to realm
public DbSet<DatabasedKeyBinding> DatabasedKeyBinding { get; set; }
private readonly string connectionString;
private static readonly Lazy<OsuDbLoggerFactory> logger = new Lazy<OsuDbLoggerFactory>(() => new OsuDbLoggerFactory());

View File

@ -0,0 +1,208 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Game.Input.Bindings;
using Realms;
namespace osu.Game.Database
{
public class RealmContextFactory : Component, IRealmFactory
{
private readonly Storage storage;
private const string database_name = @"client";
private const int schema_version = 6;
/// <summary>
/// Lock object which is held for the duration of a write operation (via <see cref="GetForWrite"/>).
/// </summary>
private readonly object writeLock = new object();
private static readonly GlobalStatistic<int> reads = GlobalStatistics.Get<int>("Realm", "Get (Read)");
private static readonly GlobalStatistic<int> writes = GlobalStatistics.Get<int>("Realm", "Get (Write)");
private static readonly GlobalStatistic<int> refreshes = GlobalStatistics.Get<int>("Realm", "Dirty Refreshes");
private static readonly GlobalStatistic<int> contexts_created = GlobalStatistics.Get<int>("Realm", "Contexts (Created)");
private static readonly GlobalStatistic<int> pending_writes = GlobalStatistics.Get<int>("Realm", "Pending writes");
private static readonly GlobalStatistic<int> active_usages = GlobalStatistics.Get<int>("Realm", "Active usages");
private readonly ManualResetEventSlim blockingResetEvent = new ManualResetEventSlim(true);
private Realm context;
public Realm Context
{
get
{
if (IsDisposed)
throw new InvalidOperationException($"Attempted to access {nameof(Context)} on a disposed context factory");
if (context == null)
{
context = createContext();
Logger.Log($"Opened realm \"{context.Config.DatabasePath}\" at version {context.Config.SchemaVersion}");
}
// creating a context will ensure our schema is up-to-date and migrated.
return context;
}
}
public RealmContextFactory(Storage storage)
{
this.storage = storage;
}
public RealmUsage GetForRead()
{
reads.Value++;
return new RealmUsage(this);
}
public RealmWriteUsage GetForWrite()
{
writes.Value++;
pending_writes.Value++;
Monitor.Enter(writeLock);
return new RealmWriteUsage(this);
}
protected override void Update()
{
base.Update();
if (context?.Refresh() == true)
refreshes.Value++;
}
private Realm createContext()
{
blockingResetEvent.Wait();
contexts_created.Value++;
return Realm.GetInstance(new RealmConfiguration(storage.GetFullPath($"{database_name}.realm", true))
{
SchemaVersion = schema_version,
MigrationCallback = onMigration,
});
}
private void onMigration(Migration migration, ulong lastSchemaVersion)
{
switch (lastSchemaVersion)
{
case 5:
// let's keep things simple. changing the type of the primary key is a bit involved.
migration.NewRealm.RemoveAll<RealmKeyBinding>();
break;
}
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
BlockAllOperations();
}
public IDisposable BlockAllOperations()
{
blockingResetEvent.Reset();
flushContexts();
return new InvokeOnDisposal<RealmContextFactory>(this, r => endBlockingSection());
}
private void endBlockingSection()
{
blockingResetEvent.Set();
}
private void flushContexts()
{
var previousContext = context;
context = null;
// wait for all threaded usages to finish
while (active_usages.Value > 0)
Thread.Sleep(50);
previousContext?.Dispose();
}
/// <summary>
/// A usage of realm from an arbitrary thread.
/// </summary>
public class RealmUsage : IDisposable
{
public readonly Realm Realm;
protected readonly RealmContextFactory Factory;
internal RealmUsage(RealmContextFactory factory)
{
active_usages.Value++;
Factory = factory;
Realm = factory.createContext();
}
/// <summary>
/// Disposes this instance, calling the initially captured action.
/// </summary>
public virtual void Dispose()
{
Realm?.Dispose();
active_usages.Value--;
}
}
/// <summary>
/// A transaction used for making changes to realm data.
/// </summary>
public class RealmWriteUsage : RealmUsage
{
private readonly Transaction transaction;
internal RealmWriteUsage(RealmContextFactory factory)
: base(factory)
{
transaction = Realm.BeginWrite();
}
/// <summary>
/// Commit all changes made in this transaction.
/// </summary>
public void Commit() => transaction.Commit();
/// <summary>
/// Revert all changes made in this transaction.
/// </summary>
public void Rollback() => transaction.Rollback();
/// <summary>
/// Disposes this instance, calling the initially captured action.
/// </summary>
public override void Dispose()
{
// rollback if not explicitly committed.
transaction?.Dispose();
base.Dispose();
Monitor.Exit(Factory.writeLock);
pending_writes.Value--;
}
}
}
}

View File

@ -0,0 +1,51 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using AutoMapper;
using osu.Game.Input.Bindings;
using Realms;
namespace osu.Game.Database
{
public static class RealmExtensions
{
private static readonly IMapper mapper = new MapperConfiguration(c =>
{
c.ShouldMapField = fi => false;
c.ShouldMapProperty = pi => pi.SetMethod != null && pi.SetMethod.IsPublic;
c.CreateMap<RealmKeyBinding, RealmKeyBinding>();
}).CreateMapper();
/// <summary>
/// Create a detached copy of the each item in the collection.
/// </summary>
/// <param name="items">A list of managed <see cref="RealmObject"/>s to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A list containing non-managed copies of provided items.</returns>
public static List<T> Detach<T>(this IEnumerable<T> items) where T : RealmObject
{
var list = new List<T>();
foreach (var obj in items)
list.Add(obj.Detach());
return list;
}
/// <summary>
/// Create a detached copy of the item.
/// </summary>
/// <param name="item">The managed <see cref="RealmObject"/> to detach.</param>
/// <typeparam name="T">The type of object.</typeparam>
/// <returns>A non-managed copy of provided item. Will return the provided item if already detached.</returns>
public static T Detach<T>(this T item) where T : RealmObject
{
if (!item.IsManaged)
return item;
return mapper.Map<T>(item);
}
}
}

3
osu.Game/FodyWeavers.xml Normal file
View File

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Realm />
</Weavers>

34
osu.Game/FodyWeavers.xsd Normal file
View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="Realm" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:attribute name="DisableAnalytics" type="xs:boolean">
<xs:annotation>
<xs:documentation>Disables anonymized usage information from being sent on build. Read more about what data is being collected and why here: https://github.com/realm/realm-dotnet/blob/master/Realm/Realm.Fody/Common/Analytics.cs</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

View File

@ -33,12 +33,18 @@ namespace osu.Game.IO
private readonly StorageConfigManager storageConfig;
private readonly Storage defaultStorage;
public override string[] IgnoreDirectories => new[] { "cache" };
public override string[] IgnoreDirectories => new[]
{
"cache",
"client.realm.management"
};
public override string[] IgnoreFiles => new[]
{
"framework.ini",
"storage.ini"
"storage.ini",
"client.realm.note",
"client.realm.lock",
};
public OsuStorage(GameHost host, Storage defaultStorage)

View File

@ -8,7 +8,7 @@ using osu.Game.Database;
namespace osu.Game.Input.Bindings
{
[Table("KeyBinding")]
public class DatabasedKeyBinding : KeyBinding, IHasPrimaryKey
public class DatabasedKeyBinding : IKeyBinding, IHasPrimaryKey
{
public int ID { get; set; }
@ -17,17 +17,23 @@ namespace osu.Game.Input.Bindings
public int? Variant { get; set; }
[Column("Keys")]
public string KeysString
{
get => KeyCombination.ToString();
private set => KeyCombination = value;
}
public string KeysString { get; set; }
[Column("Action")]
public int IntAction
public int IntAction { get; set; }
[NotMapped]
public KeyCombination KeyCombination
{
get => (int)Action;
set => Action = value;
get => KeysString;
set => KeysString = value.ToString();
}
[NotMapped]
public object Action
{
get => IntAction;
set => IntAction = (int)value;
}
}
}

View File

@ -3,10 +3,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Input.Bindings;
using osu.Game.Database;
using osu.Game.Rulesets;
using System.Linq;
using Realms;
namespace osu.Game.Input.Bindings
{
@ -21,7 +23,11 @@ namespace osu.Game.Input.Bindings
private readonly int? variant;
private KeyBindingStore store;
private IDisposable realmSubscription;
private IQueryable<RealmKeyBinding> realmKeyBindings;
[Resolved]
private RealmContextFactory realmFactory { get; set; }
public override IEnumerable<IKeyBinding> DefaultKeyBindings => ruleset.CreateInstance().GetDefaultKeyBindings(variant ?? 0);
@ -42,24 +48,34 @@ namespace osu.Game.Input.Bindings
throw new InvalidOperationException($"{nameof(variant)} can not be null when a non-null {nameof(ruleset)} is provided.");
}
[BackgroundDependencyLoader]
private void load(KeyBindingStore keyBindings)
{
store = keyBindings;
}
protected override void LoadComplete()
{
if (ruleset == null || ruleset.ID.HasValue)
{
var rulesetId = ruleset?.ID;
realmKeyBindings = realmFactory.Context.All<RealmKeyBinding>()
.Where(b => b.RulesetID == rulesetId && b.Variant == variant);
realmSubscription = realmKeyBindings
.SubscribeForNotifications((sender, changes, error) =>
{
// first subscription ignored as we are handling this in LoadComplete.
if (changes == null)
return;
ReloadMappings();
});
}
base.LoadComplete();
store.KeyBindingChanged += ReloadMappings;
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (store != null)
store.KeyBindingChanged -= ReloadMappings;
realmSubscription?.Dispose();
}
protected override void ReloadMappings()
@ -67,17 +83,17 @@ namespace osu.Game.Input.Bindings
var defaults = DefaultKeyBindings.ToList();
if (ruleset != null && !ruleset.ID.HasValue)
// if the provided ruleset is not stored to the database, we have no way to retrieve custom bindings.
// fallback to defaults instead.
// some tests instantiate a ruleset which is not present in the database.
// in these cases we still want key bindings to work, but matching to database instances would result in none being present,
// so let's populate the defaults directly.
KeyBindings = defaults;
else
{
KeyBindings = store.Query(ruleset?.ID, variant)
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.IntAction))
// this ordering is important to ensure that we read entries from the database in the order
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order.
.ToList();
KeyBindings = realmKeyBindings.Detach()
// this ordering is important to ensure that we read entries from the database in the order
// enforced by DefaultKeyBindings. allow for song select to handle actions that may otherwise
// have been eaten by the music controller due to query order.
.OrderBy(b => defaults.FindIndex(d => (int)d.Action == b.ActionInt)).ToList();
}
}
}

View File

@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Input.Bindings;
using osu.Game.Database;
using Realms;
namespace osu.Game.Input.Bindings
{
[MapTo(nameof(KeyBinding))]
public class RealmKeyBinding : RealmObject, IHasGuidPrimaryKey, IKeyBinding
{
[PrimaryKey]
public Guid ID { get; set; } = Guid.NewGuid();
public int? RulesetID { get; set; }
public int? Variant { get; set; }
public KeyCombination KeyCombination
{
get => KeyCombinationString;
set => KeyCombinationString = value.ToString();
}
public object Action
{
get => ActionInt;
set => ActionInt = (int)value;
}
[MapTo(nameof(Action))]
public int ActionInt { get; set; }
[MapTo(nameof(KeyCombination))]
public string KeyCombinationString { get; set; }
}
}

View File

@ -1,133 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using osu.Framework.Input.Bindings;
using osu.Framework.Platform;
using osu.Game.Database;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
namespace osu.Game.Input
{
public class KeyBindingStore : DatabaseBackedStore
{
public event Action KeyBindingChanged;
/// <summary>
/// Keys which should not be allowed for gameplay input purposes.
/// </summary>
private static readonly IEnumerable<InputKey> banned_keys = new[]
{
InputKey.MouseWheelDown,
InputKey.MouseWheelLeft,
InputKey.MouseWheelUp,
InputKey.MouseWheelRight
};
public KeyBindingStore(DatabaseContextFactory contextFactory, RulesetStore rulesets, Storage storage = null)
: base(contextFactory, storage)
{
using (ContextFactory.GetForWrite())
{
foreach (var info in rulesets.AvailableRulesets)
{
var ruleset = info.CreateInstance();
foreach (var variant in ruleset.AvailableVariants)
insertDefaults(ruleset.GetDefaultKeyBindings(variant), info.ID, variant);
}
}
}
public void Register(KeyBindingContainer manager) => insertDefaults(manager.DefaultKeyBindings);
/// <summary>
/// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action.
/// </summary>
/// <param name="globalAction">The action to lookup.</param>
/// <returns>A set of display strings for all the user's key configuration for the action.</returns>
public IEnumerable<string> GetReadableKeyCombinationsFor(GlobalAction globalAction)
{
foreach (var action in Query().Where(b => (GlobalAction)b.Action == globalAction))
{
string str = action.KeyCombination.ReadableString();
// even if found, the readable string may be empty for an unbound action.
if (str.Length > 0)
yield return str;
}
}
private void insertDefaults(IEnumerable<IKeyBinding> defaults, int? rulesetId = null, int? variant = null)
{
using (var usage = ContextFactory.GetForWrite())
{
// compare counts in database vs defaults
foreach (var group in defaults.GroupBy(k => k.Action))
{
int count = Query(rulesetId, variant).Count(k => (int)k.Action == (int)group.Key);
int aimCount = group.Count();
if (aimCount <= count)
continue;
foreach (var insertable in group.Skip(count).Take(aimCount - count))
{
// insert any defaults which are missing.
usage.Context.DatabasedKeyBinding.Add(new DatabasedKeyBinding
{
KeyCombination = insertable.KeyCombination,
Action = insertable.Action,
RulesetID = rulesetId,
Variant = variant
});
// required to ensure stable insert order (https://github.com/dotnet/efcore/issues/11686)
usage.Context.SaveChanges();
}
}
}
}
/// <summary>
/// Retrieve <see cref="DatabasedKeyBinding"/>s for a specified ruleset/variant content.
/// </summary>
/// <param name="rulesetId">The ruleset's internal ID.</param>
/// <param name="variant">An optional variant.</param>
public List<DatabasedKeyBinding> Query(int? rulesetId = null, int? variant = null) =>
ContextFactory.Get().DatabasedKeyBinding.Where(b => b.RulesetID == rulesetId && b.Variant == variant).ToList();
public void Update(KeyBinding keyBinding)
{
using (ContextFactory.GetForWrite())
{
var dbKeyBinding = (DatabasedKeyBinding)keyBinding;
Debug.Assert(dbKeyBinding.RulesetID == null || CheckValidForGameplay(keyBinding.KeyCombination));
Refresh(ref dbKeyBinding);
if (dbKeyBinding.KeyCombination.Equals(keyBinding.KeyCombination))
return;
dbKeyBinding.KeyCombination = keyBinding.KeyCombination;
}
KeyBindingChanged?.Invoke();
}
public static bool CheckValidForGameplay(KeyCombination combination)
{
foreach (var key in banned_keys)
{
if (combination.Keys.Contains(key))
return false;
}
return true;
}
}
}

View File

@ -0,0 +1,117 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Input.Bindings;
using osu.Game.Database;
using osu.Game.Input.Bindings;
using osu.Game.Rulesets;
#nullable enable
namespace osu.Game.Input
{
public class RealmKeyBindingStore
{
private readonly RealmContextFactory realmFactory;
public RealmKeyBindingStore(RealmContextFactory realmFactory)
{
this.realmFactory = realmFactory;
}
/// <summary>
/// Retrieve all user-defined key combinations (in a format that can be displayed) for a specific action.
/// </summary>
/// <param name="globalAction">The action to lookup.</param>
/// <returns>A set of display strings for all the user's key configuration for the action.</returns>
public IReadOnlyList<string> GetReadableKeyCombinationsFor(GlobalAction globalAction)
{
List<string> combinations = new List<string>();
using (var context = realmFactory.GetForRead())
{
foreach (var action in context.Realm.All<RealmKeyBinding>().Where(b => b.RulesetID == null && (GlobalAction)b.ActionInt == globalAction))
{
string str = action.KeyCombination.ReadableString();
// even if found, the readable string may be empty for an unbound action.
if (str.Length > 0)
combinations.Add(str);
}
}
return combinations;
}
/// <summary>
/// Register a new type of <see cref="KeyBindingContainer{T}"/>, adding default bindings from <see cref="KeyBindingContainer.DefaultKeyBindings"/>.
/// </summary>
/// <param name="container">The container to populate defaults from.</param>
public void Register(KeyBindingContainer container) => insertDefaults(container.DefaultKeyBindings);
/// <summary>
/// Register a ruleset, adding default bindings for each of its variants.
/// </summary>
/// <param name="ruleset">The ruleset to populate defaults from.</param>
public void Register(RulesetInfo ruleset)
{
var instance = ruleset.CreateInstance();
foreach (var variant in instance.AvailableVariants)
insertDefaults(instance.GetDefaultKeyBindings(variant), ruleset.ID, variant);
}
private void insertDefaults(IEnumerable<IKeyBinding> defaults, int? rulesetId = null, int? variant = null)
{
using (var usage = realmFactory.GetForWrite())
{
// compare counts in database vs defaults
foreach (var defaultsForAction in defaults.GroupBy(k => k.Action))
{
int existingCount = usage.Realm.All<RealmKeyBinding>().Count(k => k.RulesetID == rulesetId && k.Variant == variant && k.ActionInt == (int)defaultsForAction.Key);
if (defaultsForAction.Count() <= existingCount)
continue;
foreach (var k in defaultsForAction.Skip(existingCount))
{
// insert any defaults which are missing.
usage.Realm.Add(new RealmKeyBinding
{
KeyCombinationString = k.KeyCombination.ToString(),
ActionInt = (int)k.Action,
RulesetID = rulesetId,
Variant = variant
});
}
}
usage.Commit();
}
}
/// <summary>
/// Keys which should not be allowed for gameplay input purposes.
/// </summary>
private static readonly IEnumerable<InputKey> banned_keys = new[]
{
InputKey.MouseWheelDown,
InputKey.MouseWheelLeft,
InputKey.MouseWheelUp,
InputKey.MouseWheelRight
};
public static bool CheckValidForGameplay(KeyCombination combination)
{
foreach (var key in banned_keys)
{
if (combination.Keys.Contains(key))
return false;
}
return true;
}
}
}

View File

@ -608,9 +608,9 @@ namespace osu.Game
LocalConfig.LookupKeyBindings = l =>
{
var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l).ToArray();
var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l);
if (combinations.Length == 0)
if (combinations.Count == 0)
return "none";
return string.Join(" or ", combinations);

View File

@ -95,7 +95,7 @@ namespace osu.Game
protected RulesetStore RulesetStore { get; private set; }
protected KeyBindingStore KeyBindingStore { get; private set; }
protected RealmKeyBindingStore KeyBindingStore { get; private set; }
protected MenuCursorContainer MenuCursorContainer { get; private set; }
@ -144,6 +144,8 @@ namespace osu.Game
private DatabaseContextFactory contextFactory;
private RealmContextFactory realmFactory;
protected override Container<Drawable> Content => content;
private Container content;
@ -179,6 +181,9 @@ namespace osu.Game
dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage));
dependencies.Cache(realmFactory = new RealmContextFactory(Storage));
AddInternal(realmFactory);
dependencies.CacheAs(Storage);
var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore<byte[]>(Resources, @"Textures")));
@ -284,7 +289,8 @@ namespace osu.Game
dependencies.Cache(scorePerformanceManager);
AddInternal(scorePerformanceManager);
dependencies.Cache(KeyBindingStore = new KeyBindingStore(contextFactory, RulesetStore));
migrateDataToRealm();
dependencies.Cache(settingsStore = new SettingsStore(contextFactory));
dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(settingsStore));
@ -332,7 +338,12 @@ namespace osu.Game
base.Content.Add(CreateScalingContainer().WithChildren(mainContent));
KeyBindingStore = new RealmKeyBindingStore(realmFactory);
KeyBindingStore.Register(globalBindings);
foreach (var r in RulesetStore.AvailableRulesets)
KeyBindingStore.Register(r);
dependencies.Cache(globalBindings);
PreviewTrackManager previewTrackManager;
@ -387,8 +398,11 @@ namespace osu.Game
public void Migrate(string path)
{
contextFactory.FlushConnections();
(Storage as OsuStorage)?.Migrate(Host.GetStorage(path));
using (realmFactory.BlockAllOperations())
{
contextFactory.FlushConnections();
(Storage as OsuStorage)?.Migrate(Host.GetStorage(path));
}
}
protected override UserInputManager CreateUserInputManager() => new OsuUserInputManager();
@ -399,6 +413,34 @@ namespace osu.Game
protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage);
private void migrateDataToRealm()
{
using (var db = contextFactory.GetForWrite())
using (var usage = realmFactory.GetForWrite())
{
var existingBindings = db.Context.DatabasedKeyBinding;
// only migrate data if the realm database is empty.
if (!usage.Realm.All<RealmKeyBinding>().Any())
{
foreach (var dkb in existingBindings)
{
usage.Realm.Add(new RealmKeyBinding
{
KeyCombinationString = dkb.KeyCombination.ToString(),
ActionInt = (int)dkb.Action,
RulesetID = dkb.RulesetID,
Variant = dkb.Variant
});
}
}
db.Context.RemoveRange(existingBindings);
usage.Commit();
}
}
private void onRulesetChanged(ValueChangedEvent<RulesetInfo> r)
{
var dict = new Dictionary<ModType, IReadOnlyList<Mod>>();

View File

@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Allocation;
@ -13,6 +14,7 @@ using osu.Framework.Graphics.Effects;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
@ -27,7 +29,7 @@ namespace osu.Game.Overlays.KeyBinding
public class KeyBindingRow : Container, IFilterable
{
private readonly object action;
private readonly IEnumerable<Framework.Input.Bindings.KeyBinding> bindings;
private readonly IEnumerable<RealmKeyBinding> bindings;
private const float transition_time = 150;
@ -62,7 +64,7 @@ namespace osu.Game.Overlays.KeyBinding
public IEnumerable<string> FilterTerms => bindings.Select(b => b.KeyCombination.ReadableString()).Prepend(text.Text.ToString());
public KeyBindingRow(object action, IEnumerable<Framework.Input.Bindings.KeyBinding> bindings)
public KeyBindingRow(object action, List<RealmKeyBinding> bindings)
{
this.action = action;
this.bindings = bindings;
@ -72,7 +74,7 @@ namespace osu.Game.Overlays.KeyBinding
}
[Resolved]
private KeyBindingStore store { get; set; }
private RealmContextFactory realmFactory { get; set; }
[BackgroundDependencyLoader]
private void load(OsuColour colours)
@ -153,7 +155,8 @@ namespace osu.Game.Overlays.KeyBinding
{
var button = buttons[i++];
button.UpdateKeyCombination(d);
store.Update(button.KeyBinding);
updateStoreFromButton(button);
}
isDefault.Value = true;
@ -314,7 +317,7 @@ namespace osu.Game.Overlays.KeyBinding
{
if (bindTarget != null)
{
store.Update(bindTarget.KeyBinding);
updateStoreFromButton(bindTarget);
updateIsDefaultValue();
@ -361,6 +364,17 @@ namespace osu.Game.Overlays.KeyBinding
if (bindTarget != null) bindTarget.IsBinding = true;
}
private void updateStoreFromButton(KeyButton button)
{
using (var usage = realmFactory.GetForWrite())
{
var binding = usage.Realm.Find<RealmKeyBinding>(((IHasGuidPrimaryKey)button.KeyBinding).ID);
binding.KeyCombinationString = button.KeyBinding.KeyCombinationString;
usage.Commit();
}
}
private void updateIsDefaultValue()
{
isDefault.Value = bindings.Select(b => b.KeyCombination).SequenceEqual(Defaults);
@ -386,7 +400,7 @@ namespace osu.Game.Overlays.KeyBinding
public class KeyButton : Container
{
public readonly Framework.Input.Bindings.KeyBinding KeyBinding;
public readonly RealmKeyBinding KeyBinding;
private readonly Box box;
public readonly OsuSpriteText Text;
@ -408,8 +422,11 @@ namespace osu.Game.Overlays.KeyBinding
}
}
public KeyButton(Framework.Input.Bindings.KeyBinding keyBinding)
public KeyButton(RealmKeyBinding keyBinding)
{
if (keyBinding.IsManaged)
throw new ArgumentException("Key binding should not be attached as we make temporary changes", nameof(keyBinding));
KeyBinding = keyBinding;
Margin = new MarginPadding(padding);
@ -478,7 +495,7 @@ namespace osu.Game.Overlays.KeyBinding
public void UpdateKeyCombination(KeyCombination newCombination)
{
if ((KeyBinding as DatabasedKeyBinding)?.RulesetID != null && !KeyBindingStore.CheckValidForGameplay(newCombination))
if (KeyBinding.RulesetID != null && !RealmKeyBindingStore.CheckValidForGameplay(newCombination))
return;
KeyBinding.KeyCombination = newCombination;

View File

@ -6,8 +6,9 @@ using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Framework.Graphics;
using osu.Game.Database;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osu.Game.Overlays.Settings;
using osu.Game.Rulesets;
using osuTK;
@ -31,16 +32,21 @@ namespace osu.Game.Overlays.KeyBinding
}
[BackgroundDependencyLoader]
private void load(KeyBindingStore store)
private void load(RealmContextFactory realmFactory)
{
var bindings = store.Query(Ruleset?.ID, variant);
var rulesetId = Ruleset?.ID;
List<RealmKeyBinding> bindings;
using (var usage = realmFactory.GetForRead())
bindings = usage.Realm.All<RealmKeyBinding>().Where(b => b.RulesetID == rulesetId && b.Variant == variant).Detach();
foreach (var defaultGroup in Defaults.GroupBy(d => d.Action))
{
int intKey = (int)defaultGroup.Key;
// one row per valid action.
Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => ((int)b.Action).Equals(intKey)))
Add(new KeyBindingRow(defaultGroup.Key, bindings.Where(b => b.ActionInt.Equals(intKey)).ToList())
{
AllowMainMouseButtons = Ruleset != null,
Defaults = defaultGroup.Select(d => d.KeyCombination)

View File

@ -1,8 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using osu.Framework.Allocation;
using osu.Framework.Caching;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Extensions.EnumExtensions;
using osu.Framework.Graphics;
@ -13,13 +13,13 @@ using osu.Framework.Graphics.Sprites;
using osu.Framework.Graphics.Textures;
using osu.Framework.Input.Bindings;
using osu.Framework.Input.Events;
using osu.Game.Database;
using osu.Framework.Localisation;
using osu.Game.Graphics;
using osu.Game.Graphics.Backgrounds;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface;
using osu.Game.Input;
using osu.Game.Input.Bindings;
using osuTK;
using osuTK.Graphics;
@ -76,7 +76,7 @@ namespace osu.Game.Overlays.Toolbar
protected FillFlowContainer Flow;
[Resolved]
private KeyBindingStore keyBindings { get; set; }
private RealmContextFactory realmFactory { get; set; }
protected ToolbarButton()
: base(HoverSampleSet.Toolbar)
@ -159,27 +159,28 @@ namespace osu.Game.Overlays.Toolbar
};
}
private readonly Cached tooltipKeyBinding = new Cached();
private RealmKeyBinding realmKeyBinding;
[BackgroundDependencyLoader]
private void load()
protected override void LoadComplete()
{
keyBindings.KeyBindingChanged += () => tooltipKeyBinding.Invalidate();
base.LoadComplete();
if (Hotkey == null) return;
realmKeyBinding = realmFactory.Context.All<RealmKeyBinding>().FirstOrDefault(rkb => rkb.RulesetID == null && rkb.ActionInt == (int)Hotkey.Value);
if (realmKeyBinding != null)
{
realmKeyBinding.PropertyChanged += (sender, args) =>
{
if (args.PropertyName == nameof(realmKeyBinding.KeyCombinationString))
updateKeyBindingTooltip();
};
}
updateKeyBindingTooltip();
}
private void updateKeyBindingTooltip()
{
if (tooltipKeyBinding.IsValid)
return;
var binding = keyBindings.Query().Find(b => (GlobalAction)b.Action == Hotkey);
var keyBindingString = binding?.KeyCombination.ReadableString();
keyBindingTooltip.Text = !string.IsNullOrEmpty(keyBindingString) ? $" ({keyBindingString})" : string.Empty;
tooltipKeyBinding.Validate();
}
protected override bool OnMouseDown(MouseDownEvent e) => true;
protected override bool OnClick(ClickEvent e)
@ -218,6 +219,17 @@ namespace osu.Game.Overlays.Toolbar
public void OnReleased(GlobalAction action)
{
}
private void updateKeyBindingTooltip()
{
if (realmKeyBinding != null)
{
var keyBindingString = realmKeyBinding.KeyCombination.ReadableString();
if (!string.IsNullOrEmpty(keyBindingString))
keyBindingTooltip.Text = $" ({keyBindingString})";
}
}
}
public class OpaqueBackground : Container

View File

@ -177,7 +177,7 @@ namespace osu.Game.Rulesets.UI
{
base.ReloadMappings();
KeyBindings = KeyBindings.Where(b => KeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
KeyBindings = KeyBindings.Where(b => RealmKeyBindingStore.CheckValidForGameplay(b.KeyCombination)).ToList();
}
}
}

View File

@ -18,6 +18,7 @@
</None>
</ItemGroup>
<ItemGroup Label="Package References">
<PackageReference Include="AutoMapper" Version="10.1.1" />
<PackageReference Include="DiffPlex" Version="1.7.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.33" />
<PackageReference Include="Humanizer" Version="2.10.1" />
@ -34,6 +35,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="10.2.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.616.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.618.0" />
<PackageReference Include="Sentry" Version="3.4.0" />