Avalonia UI shell. Based on TKMM.

ImGui was very fun to use but ultimately, it's very intensive for what it is. It showed 3 panes and sat at 20% CPU usage on my 13700F. I'm not sure if my code was just bad or if this is how ImGui is.
This commit is contained in:
Evan Husted 2024-08-03 21:04:10 -05:00
parent e4e66f3ec3
commit 13cd4d264e
29 changed files with 455 additions and 1191 deletions

18
.vscode/launch.json vendored
View file

@ -1,6 +1,22 @@
{
"version": "0.2.0",
"configurations": [
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
// For further information visit https://github.com/OmniSharp/omnisharp-vscode/blob/master/debugger-launchjson.md
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/src/UI/bin/Debug/net8.0/Volte.UI.dll",
"args": [],
"cwd": "${workspaceFolder}/src/UI/bin/Debug/net8.0",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "integratedTerminal",
"stopAtEntry": false
},
{
// Use IntelliSense to find out which attributes exist for C# debugging
// Use hover for the description of the existing attributes
@ -11,7 +27,7 @@
"preLaunchTask": "build",
// If you have changed target frameworks, make sure to update the program path.
"program": "${workspaceFolder}/src/Bot/bin/Debug/net8.0/Volte.dll",
"args": ["--ui"],
"args": [],
"cwd": "${workspaceFolder}/src/Bot/bin/Debug/net8.0",
// For more information about the 'console' field, see https://aka.ms/VSCode-CS-LaunchJson-Console
"console": "integratedTerminal",

View file

@ -5,7 +5,7 @@ VisualStudioVersion = 16.0.28721.148
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Volte", "src\Bot\Volte.csproj", "{5D4A85B0-1326-4CA2-A26C-D646D9579342}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volte.UI", "src\UI\Volte.UI.csproj", "{70624B23-1B94-4801-9536-DD4FE86E6671}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Volte.UI", "src\UI\Volte.UI.csproj", "{BCD42127-89FE-44F3-AD2F-D224ED94B03A}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -23,14 +23,14 @@ Global
{5D4A85B0-1326-4CA2-A26C-D646D9579342}.Release|Any CPU.Build.0 = Release|Any CPU
{5D4A85B0-1326-4CA2-A26C-D646D9579342}.Release|x64.ActiveCfg = Release|x64
{5D4A85B0-1326-4CA2-A26C-D646D9579342}.Release|x64.Build.0 = Release|x64
{70624B23-1B94-4801-9536-DD4FE86E6671}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{70624B23-1B94-4801-9536-DD4FE86E6671}.Debug|Any CPU.Build.0 = Debug|Any CPU
{70624B23-1B94-4801-9536-DD4FE86E6671}.Debug|x64.ActiveCfg = Debug|Any CPU
{70624B23-1B94-4801-9536-DD4FE86E6671}.Debug|x64.Build.0 = Debug|Any CPU
{70624B23-1B94-4801-9536-DD4FE86E6671}.Release|Any CPU.ActiveCfg = Release|Any CPU
{70624B23-1B94-4801-9536-DD4FE86E6671}.Release|Any CPU.Build.0 = Release|Any CPU
{70624B23-1B94-4801-9536-DD4FE86E6671}.Release|x64.ActiveCfg = Release|Any CPU
{70624B23-1B94-4801-9536-DD4FE86E6671}.Release|x64.Build.0 = Release|Any CPU
{BCD42127-89FE-44F3-AD2F-D224ED94B03A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{BCD42127-89FE-44F3-AD2F-D224ED94B03A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{BCD42127-89FE-44F3-AD2F-D224ED94B03A}.Debug|x64.ActiveCfg = Debug|Any CPU
{BCD42127-89FE-44F3-AD2F-D224ED94B03A}.Debug|x64.Build.0 = Debug|Any CPU
{BCD42127-89FE-44F3-AD2F-D224ED94B03A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{BCD42127-89FE-44F3-AD2F-D224ED94B03A}.Release|Any CPU.Build.0 = Release|Any CPU
{BCD42127-89FE-44F3-AD2F-D224ED94B03A}.Release|x64.ActiveCfg = Release|Any CPU
{BCD42127-89FE-44F3-AD2F-D224ED94B03A}.Release|x64.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View file

@ -1,21 +0,0 @@
using Volte.UI;
namespace Volte.Commands.Text.Modules;
public sealed partial class BotOwnerModule
{
[Command("CreateUi", "Cui")]
[Description("Create the ImGui UI on the machine running Volte.")]
public Task<ActionResult> UiAsync(
[Description("Desired font size of the UI.")] int fontSize = 17)
{
var uiParams = VolteBot.GetUiParams(fontSize);
if (!UiManager.TryCreateUi(uiParams, out var err))
return BadRequest($"Could not create UI thread: {err?.Message}");
UiManager.AddView(new VolteUiView());
UiManager.StartThread("Volte UI Thread");
return None(() => Context.Message.AddReactionAsync(Emojis.BallotBoxWithCheck));
}
}

View file

@ -0,0 +1,12 @@
namespace Volte.Entities;
#nullable enable
public class VolteLogEventArgs
{
public required LogSeverity Severity;
public required LogSource Source;
public required string Message;
public required string[] PrintedLines;
public required Exception? Error;
}

View file

@ -1,13 +1,4 @@
using System.IO;
using ImGuiNET;
using Silk.NET.Maths;
using Silk.NET.OpenGL.Extensions.ImGui;
using Silk.NET.Windowing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using Volte.Commands.Text.Modules;
using Volte.UI;
using Image = SixLabors.ImageSharp.Image;
namespace Volte;
@ -15,7 +6,7 @@ public class VolteBot
{
public static Task StartAsync()
{
Console.Title = DefaultWindowOptions.Title;
Console.Title = $"Volte {Version.InformationVersion}";
Console.CursorVisible = false;
return new VolteBot().LoginAsync();
}
@ -39,9 +30,6 @@ public class VolteBot
ServiceProvider = new ServiceCollection().AddAllServices().BuildServiceProvider();
if (Program.CommandLineArguments.TryGetValue("ui", out var sizeStr))
CreateUi(sizeStr);
_client = ServiceProvider.Get<DiscordSocketClient>();
_cts = ServiceProvider.Get<CancellationTokenSource>();
@ -96,64 +84,4 @@ public class VolteBot
Environment.Exit(0);
}
// WindowOptions.Default with custom title and larger base window
public static readonly WindowOptions DefaultWindowOptions = new(
isVisible: true,
position: new Vector2D<int>(50, 50),
size: new Vector2D<int>(1600, 900),
framesPerSecond: 0,
updatesPerSecond: 0.0,
api: GraphicsAPI.Default,
title: $"Volte {Version.InformationVersion}",
windowState: WindowState.Normal,
windowBorder: WindowBorder.Resizable,
isVSync: true,
shouldSwapAutomatically: true,
videoMode: VideoMode.Default
);
private static void CreateUi(string sizeStr)
{
var uiParams = GetUiParams(sizeStr.TryParse<int>(out var fsz) ? fsz : 17);
if (UiManager.TryCreateUi(uiParams, out var uiStartError))
{
UiManager.AddView(new VolteUiView());
UiManager.StartThread("Volte UI Thread");
}
else Error(LogSource.UI, $"Could not create UI: {uiStartError!.Message}");
}
private static readonly string[] UiFontResourceKeys = [ "Regular", "Bold", "BoldItalic", "Italic" ];
public static UiManager.CreateParams GetUiParams(int fontSize)
{
unsafe
{
return new UiManager.CreateParams
{
WindowIcon = loadIcon(),
WOptions = DefaultWindowOptions,
Theme = Spectrum.Dark,
OnConfigureIo = _ =>
{
UiFontResourceKeys.ForEach(key =>
{
using var embeddedFont = Assembly.GetExecutingAssembly().GetManifestResourceStream(key);
if (embeddedFont != null)
UiManager.LoadFontFromStream(embeddedFont, key, fontSize);
});
}
};
}
Image<Rgba32> loadIcon()
{
using var iconStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("VolteIcon");
return iconStream == null
? null
: Image.Load<Rgba32>(iconStream);
}
}
}

68
src/Bot/Helpers/Event.cs Normal file
View file

@ -0,0 +1,68 @@
using System.Collections.Immutable;
namespace Volte.Helpers;
internal class Event<T>
where T : class
{
private readonly object _subLock = new();
private ImmutableArray<T> _subscriptions = [];
public bool HasSubscribers
{
get
{
lock (_subLock)
return _subscriptions.Length != 0;
}
}
public IReadOnlyList<T> Subscriptions
{
get
{
lock (_subLock)
return _subscriptions;
}
}
public void Add(T subscriber)
{
Guard.Require(subscriber, nameof(subscriber));
lock (_subLock)
_subscriptions = _subscriptions.Add(subscriber);
}
public void Remove(T subscriber)
{
Guard.Require(subscriber, nameof(subscriber));
lock (_subLock)
_subscriptions = _subscriptions.Remove(subscriber);
}
}
internal static class EventExtensions
{
public static void Call(this Event<Action> eventHandler)
=> eventHandler.Subscriptions.ForEach(x => x());
public static void Call<T>(this Event<Action<T>> eventHandler,
T arg
) => eventHandler.Subscriptions.ForEach(x => x(arg));
public static void Call<T1, T2>(this Event<Action<T1, T2>> eventHandler,
T1 arg1, T2 arg2
) => eventHandler.Subscriptions.ForEach(x => x(arg1, arg2));
public static void Call<T1, T2, T3>(this Event<Action<T1, T2, T3>> eventHandler,
T1 arg1, T2 arg2, T3 arg3
) => eventHandler.Subscriptions.ForEach(x => x(arg1, arg2, arg3));
public static void Call<T1, T2, T3, T4>(this Event<Action<T1, T2, T3, T4>> eventHandler,
T1 arg1, T2 arg2, T3 arg3, T4 arg4
) => eventHandler.Subscriptions.ForEach(x => x(arg1, arg2, arg3, arg4));
public static void Call<T1, T2, T3, T4, T5>(this Event<Action<T1, T2, T3, T4, T5>> eventHandler,
T1 arg1, T2 arg2, T3 arg3, T4 arg4, T5 arg5
) => eventHandler.Subscriptions.ForEach(x => x(arg1, arg2, arg3, arg4, arg5));
}

View file

@ -55,7 +55,7 @@ public static partial class Logger
{
if (_logFileNoticePrinted || !(Config.EnabledFeatures?.LogToFile ?? false)) return;
GetRelevantLogPath().AppendAllText($"{Side}RESTARTING{Side}\n");
GetLogFilePath(DateTime.Now).AppendAllText($"{Side}RESTARTING{Side}\n");
_logFileNoticePrinted = true;
}
@ -113,10 +113,14 @@ public static partial class Logger
if (e != null)
{
e.SentryCapture(scope =>
scope.AddBreadcrumb("This exception might not have been thrown, and may not be important; it is merely being logged."));
Append(Environment.NewLine + (e.Message.IsNullOrEmpty() ? "No message provided" : e.Message) +
Environment.NewLine + e.StackTrace,
Color.IndianRed, ref content);
scope.AddBreadcrumb("This exception might not have been thrown, and may not be important; it is merely being logged.")
);
Append(errorString(), Color.IndianRed, ref content);
string errorString()
=> Environment.NewLine + (e.Message.IsNullOrEmpty() ? "No message provided" : e.Message) +
Environment.NewLine + e.StackTrace;
}
if (Environment.NewLine != content[^1].ToString())
@ -126,12 +130,22 @@ public static partial class Logger
}
if (Config.EnabledFeatures?.LogToFile ?? false)
GetRelevantLogPath().AppendAllText(content.ToString().TrimEnd('\n').Append("\n"));
GetLogFilePath(DateTime.Now).AppendAllText(content.ToString().TrimEnd('\n').Append("\n"));
if (!_logEventHandler.HasSubscribers) return;
_logEventHandler.Call(new VolteLogEventArgs
{
Severity = s,
Source = src,
Message = message,
PrintedLines = content.ToString().TrimEnd('\n').Split('\n', StringSplitOptions.RemoveEmptyEntries),
Error = e
});
}
private static FilePath GetLogFilePath(DateTime date) => new FilePath("logs") / string.Intern($"{date.Month}-{date.Day}-{date.Year}.log");
private static FilePath GetRelevantLogPath() => GetLogFilePath(DateTime.Now);
private static FilePath GetLogFilePath(DateTime date)
=> new FilePath("logs") / string.Intern($"{date.Year}-{date.Month}-{date.Day}.log");
private static void Append(string m, Color c)
{

View file

@ -1,12 +1,18 @@
using System.IO;
using System.Runtime.CompilerServices;
using Color = System.Drawing.Color;
using Optional = Gommon.Optional;
namespace Volte.Helpers;
public static partial class Logger
{
public static event Action<VolteLogEventArgs> LogEvent
{
add => _logEventHandler.Add(value);
remove => _logEventHandler.Remove(value);
}
private static readonly Event<Action<VolteLogEventArgs>> _logEventHandler = new();
public static bool IsDebugLoggingEnabled => Config.DebugEnabled || Version.IsDevelopment;
public static void HandleLogEvent(LogEventArgs args) =>
@ -71,8 +77,9 @@ public static partial class Logger
/// This method calls <see cref="SentrySdk"/>'s CaptureException, so it is logged to Sentry.
/// </summary>
/// <param name="e">Exception to print.</param>
public static void Error<TData>(Exception e, InvocationInfo<TData> caller)
=> Execute(LogSeverity.Error, LogSource.Volte, string.Empty, e, caller);
/// <param name="src">Source to print the message from.</param>
public static void Error<TData>(Exception e, InvocationInfo<TData> caller, LogSource src = LogSource.Volte)
=> Execute(LogSeverity.Error, src, string.Empty, e, caller);
#endregion

View file

@ -1,5 +1,4 @@
using Discord.Interactions;
using Silk.NET.Input;
using Volte.Interactions;
using RunMode = Qmmands.RunMode;

View file

@ -12,7 +12,12 @@ public static class Program
if (output.Error is not InvalidOperationException)
Error(output.Error);
CommandLineArguments = new ReadOnlyDictionary<string, string>(output.Parsed ?? new Dictionary<string, string>());
await Main(output.Parsed);
}
private static async Task Main(Dictionary<string, string> args)
{
CommandLineArguments = new ReadOnlyDictionary<string, string>(args ?? new Dictionary<string, string>());
await VolteBot.StartAsync();
}
}

View file

@ -1,145 +0,0 @@
using System.Collections.Immutable;
// needed for commented code //using System.Numerics;
using ImGuiNET;
using Silk.NET.Input;
using Color = System.Drawing.Color;
namespace Volte.UI;
public partial class VolteUiView
{
private void CommandStats(double _)
{
//using var __ = PushStyle(ImGuiStyleVar.WindowMinSize, new Vector2(201, 188));
ImGui.SeparatorText("Total executions");
Gui.SameLineText($"Successful: {_state.Messages.AllTimeSuccessfulCommandCalls}", Color.LawnGreen);
Gui.SameLineText("+");
Gui.SameLineText($"Failed: {_state.Messages.AllTimeFailedCommandCalls}", Color.OrangeRed);
Gui.SameLineText("=");
ImGui.Text($"Total: {_state.Messages.AllTimeCommandCalls}");
ImGui.SeparatorText("This Session");
Gui.SameLineText($"Successful: {CalledCommandsInfo.ThisSessionSuccess + _state.Messages.UnsavedSuccessfulCommandCalls}", Color.LawnGreen);
Gui.SameLineText("+");
Gui.SameLineText($"Failed: {CalledCommandsInfo.ThisSessionFailed + _state.Messages.UnsavedFailedCommandCalls}", Color.OrangeRed);
Gui.SameLineText("=");
ImGui.Text($"Total: {
CalledCommandsInfo.ThisSessionSuccess + CalledCommandsInfo.ThisSessionFailed +
_state.Messages.UnsavedFailedCommandCalls + _state.Messages.UnsavedSuccessfulCommandCalls
}");
}
private void BotManagement(double _)
{
//using var __ = PushStyle(ImGuiStyleVar.WindowMinSize, new Vector2(385, 299));
Gui.SameLineText("Discord Gateway:");
// ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault
// default is a meaningless case here i dont fucking care rider
switch (_state.Client.ConnectionState)
{
case ConnectionState.Connected:
ColoredText("Connected", Color.LawnGreen);
break;
case ConnectionState.Connecting:
ColoredText("Connecting...", Color.Yellow);
break;
case ConnectionState.Disconnecting:
ColoredText("Disconnecting...", Color.OrangeRed);
break;
case ConnectionState.Disconnected:
ColoredText("Disconnected!", Color.Red);
break;
}
if (_state.Client.ConnectionState == ConnectionState.Connected)
{
ImGui.Text($"Connected as: {_state.Client.CurrentUser.Username}#{_state.Client.CurrentUser.DiscriminatorValue}");
// ToString()ing the CurrentUser has weird question marks on both sides of Volte-dev's name,
// so we do it manually in case that happens on other bot accounts too
var currentStatus = _state.Client.Status;
if (ImGui.BeginMenu($"Bot status: {currentStatus}"))
{
if (ImGui.MenuItem("Online", currentStatus != UserStatus.Online))
Await(_state.Client.SetStatusAsync(UserStatus.Online));
if (ImGui.MenuItem("Idle", currentStatus != UserStatus.Idle))
Await(_state.Client.SetStatusAsync(UserStatus.Idle));
if (ImGui.MenuItem("Do Not Disturb", currentStatus != UserStatus.DoNotDisturb))
Await(_state.Client.SetStatusAsync(UserStatus.DoNotDisturb));
if (ImGui.MenuItem("Invisible", currentStatus != UserStatus.Invisible))
Await(_state.Client.SetStatusAsync(UserStatus.Invisible));
ImGui.EndMenu();
}
}
var process = Process.GetCurrentProcess();
ImGui.Text($"Process memory: {process.GetMemoryUsage()} ({process.GetMemoryUsage(MemoryType.Kilobytes)})");
if (ImGui.Button("Reload Config"))
Config.Reload();
}
#region Guild Manager
private void GuildManager(double _)
{
//using var __ = PushStyle(ImGuiStyleVar.WindowMinSize, new Vector2(418, 300));
if (_state.SelectedGuildId != 0)
{
var selectedGuild = _state.Client.GetGuild(_state.SelectedGuildId);
var selectedGuildMembers = selectedGuild.Users.ToImmutableArray();
var botMembers = selectedGuildMembers.Count(sgu => sgu.IsBot);
ImGui.SeparatorText(selectedGuild.Name);
ImGui.Text($"Owner: @{selectedGuild.Owner}");
ImGui.Text($"Text Channels: {selectedGuild.TextChannels.Count}");
ImGui.Text($"Voice Channels: {selectedGuild.VoiceChannels.Count}");
Gui.SameLineText($"{selectedGuildMembers.Length} members |");
Gui.SameLineText($"{selectedGuildMembers.Length - botMembers} users", Color.LawnGreen);
Gui.SameLineText("|");
ColoredText($"{botMembers} bots", Color.OrangeRed);
ImGui.Separator();
var destructiveMenuEnabled = AllKeysPressed(Key.ShiftLeft, Key.ControlLeft);
if (ImGui.BeginMenu("Destructive Actions (Shift + Ctrl)", destructiveMenuEnabled))
{
if (ImGui.MenuItem("Leave Guild", destructiveMenuEnabled))
{
Await(selectedGuild.LeaveAsync());
_state.SelectedGuildId = 0; //resets this pane back to just the "select a guild" button
}
if (ImGui.MenuItem("Reset Configuration", destructiveMenuEnabled))
_state.Database.Save(GuildData.CreateFrom(selectedGuild));
ImGui.EndMenu();
}
ImGui.Separator();
}
GuildSelect();
}
private void GuildSelect()
{
if (!ImGui.BeginMenu("Select a Guild")) return;
_state.Client.Guilds.ForEach(guild =>
{
if (ImGui.MenuItem(guild.Name, guild.Id != _state.SelectedGuildId))
_state.SelectedGuildId = guild.Id;
});
ImGui.EndMenu();
}
#endregion Guild Manager
}

View file

@ -1,85 +0,0 @@
using ImGuiNET;
using Color = System.Drawing.Color;
// ReSharper disable InvertIf
namespace Volte.UI;
public sealed class VolteUiState
{
public bool SelectedTheme = true;
public bool ShowStyleEditor;
public VolteUiState(IServiceProvider provider)
{
Cts = provider.Get<CancellationTokenSource>();
Client = provider.Get<DiscordSocketClient>();
Messages = provider.Get<MessageService>();
Database = provider.Get<DatabaseService>();
}
public readonly CancellationTokenSource Cts;
public readonly DiscordSocketClient Client;
public readonly MessageService Messages;
public readonly DatabaseService Database;
public ulong SelectedGuildId = 0;
}
public partial class VolteUiView : UiView
{
private readonly VolteUiState _state;
public VolteUiView()
{
_state = new VolteUiState(VolteBot.ServiceProvider);
MainMenuBar = MenuBar;
Panel("Command Stats", CommandStats);
Panel("Bot Management", BotManagement);
Panel("Guild Manager", GuildManager);
Panel(_ =>
{
if (_state.ShowStyleEditor)
{
ImGui.Begin("Style Editor");
ImGui.ShowStyleEditor(ImGui.GetStyle());
ImGui.End();
}
});
}
private void MenuBar(double delta)
{
if (ImGui.BeginMenu("File"))
{
if (ImGui.Button("Shutdown"))
_state.Cts.Cancel();
ImGui.EndMenu();
}
if (ImGui.BeginMenu("Theming"))
{
if (ImGui.MenuItem(_state.SelectedTheme ? "Swap to Light" : "Swap to Dark"))
{
_state.SelectedTheme = !_state.SelectedTheme;
unsafe
{
UiManager.SetColors(_state.SelectedTheme ? Spectrum.Dark : Spectrum.Light);
}
}
if (ImGui.RadioButton("Show Style Editor", _state.ShowStyleEditor))
_state.ShowStyleEditor = !_state.ShowStyleEditor;
ImGui.EndMenu();
}
ImGui.BeginMenu($"{Io.Framerate:###} FPS", false);
}
private static void ColoredText(string fmt, Color color) =>
ImGui.TextColored(color.AsVec4(), fmt);
}

View file

@ -31,10 +31,10 @@
<EmbeddedResource Include="Resources/Mono/JetBrainsMonoNL-Italic.ttf" LogicalName="Italic" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Colorful.Console" Version="1.2.15" />
<PackageReference Include="Discord.Net.Interactions" Version="3.15.3" />
<PackageReference Include="Discord.Net.WebSocket" Version="3.15.3" />
<PackageReference Include="Gommon" Version="2.6.4" />
<PackageReference Include="GreemDev.Colorful.Console" Version="1.3.0" />
<PackageReference Include="Humanizer.Core" Version="2.14.1" />
<PackageReference Include="LiteDB" Version="5.0.21" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="4.10.0" />
@ -43,9 +43,6 @@
<PackageReference Include="Sentry" Version="4.9.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../UI/Volte.UI.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Resources\" />
</ItemGroup>

26
src/UI/Avalonia/App.axaml Normal file
View file

@ -0,0 +1,26 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sty="using:FluentAvalonia.Styling"
x:Class="Volte.UI.App">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="SystemChromeHighColor">#FFACACAC</SolidColorBrush>
<SolidColorBrush x:Key="SystemChromeLowColor">Transparent</SolidColorBrush>
<SolidColorBrush x:Key="SystemChromeMediumColor">#FFF2F2F2</SolidColorBrush>
<SolidColorBrush x:Key="SystemChromeMediumLowColor">#FFE6E6E6</SolidColorBrush>
</ResourceDictionary>
<ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="SystemChromeHighColor">#FF737373</SolidColorBrush>
<SolidColorBrush x:Key="SystemChromeLowColor">Transparent</SolidColorBrush>
<SolidColorBrush x:Key="SystemChromeMediumColor">#FF2B2B2B</SolidColorBrush>
<SolidColorBrush x:Key="SystemChromeMediumLowColor">#FF1F1F1F</SolidColorBrush>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
</ResourceDictionary>
</Application.Resources>
<Application.Styles>
<sty:FluentAvaloniaTheme PreferUserAccentColor="True" />
</Application.Styles>
</Application>

View file

@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace Volte.UI;
public class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new UIShellView();
}
base.OnFrameworkInitializationCompleted();
}
}

View file

@ -0,0 +1,71 @@
<faw:AppWindow xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:fac="using:FluentAvalonia.UI.Controls"
xmlns:faw="using:FluentAvalonia.UI.Windowing"
xmlns:v="using:Volte"
xmlns:local="using:Volte.UI"
xmlns:h="using:Volte.UI.Helpers"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="Volte.UI.UIShellView"
Title="{Binding Title}">
<faw:AppWindow.DataContext>
<local:UIShellViewModel />
</faw:AppWindow.DataContext>
<Grid Background="Transparent" RowDefinitions="32,*,25">
<Grid ColumnDefinitions="Auto,*">
<Viewbox Width="26"
Height="48"
MaxWidth="48"
MinWidth="48"
Margin="3,4"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Stretch="Fill">
<Grid>
<Viewbox Width="12"
Height="12"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<Border Padding="25">
<Image Source="{Binding Icon}"></Image>
</Border>
</Viewbox>
</Grid>
</Viewbox>
<Menu Name="MainMenu" Grid.Column="1" />
</Grid>
<fac:NavigationView Name="MainNavigation"
Grid.Row="1"
FooterMenuItemsSource="{Binding FooterPages, Source={x:Static h:PageManager.Shared}}"
IsSettingsVisible="False"
MenuItemsSource="{Binding Pages, Source={x:Static h:PageManager.Shared}}"
PaneDisplayMode="LeftCompact"
PaneTitle="{Binding Title}"
SelectedItem="{Binding Current, Mode=TwoWay, Source={x:Static h:PageManager.Shared}}">
<fac:NavigationView.MenuItemTemplate>
<DataTemplate x:DataType="h:PageData">
<fac:NavigationViewItem Content="{Binding Title}"
IconSource="{Binding Icon}"
Tag="{Binding Content}"
ToolTip.Tip="{Binding Description}" />
</DataTemplate>
</fac:NavigationView.MenuItemTemplate>
</fac:NavigationView>
<Grid Grid.Row="2"
ColumnDefinitions="Auto,Auto,*,Auto,Auto"
IsHitTestVisible="False">
<Border Grid.ColumnSpan="5" Background="{DynamicResource SystemAccentColor}" />
<TextBlock Grid.Column="3"
Margin="5,0"
VerticalAlignment="Center"
FontSize="12">
<Run Text="{Binding Title}" />
<Run Text=" |" />
<Run Text="{Binding Source={x:Static v:Version.InformationVersion}}" />
</TextBlock>
</Grid>
</Grid>
</faw:AppWindow>

View file

@ -0,0 +1,37 @@
using Avalonia;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using FluentAvalonia.UI.Windowing;
using Gommon;
namespace Volte.UI;
public partial class UIShellView : AppWindow
{
public UIShellView()
{
InitializeComponent();
TitleBar.ExtendsContentIntoTitleBar = true;
TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex;
using var bitmap = new Bitmap(AssetLoader.Open(new Uri("avares://Volte.UI/Assets/icon.ico")));
Icon = bitmap.CreateScaledBitmap(new PixelSize(48, 48));
DataContext = new UIShellViewModel
{
OpenDevTools = new KeyGesture(Key.F4, KeyModifiers.Control),
Icon = Icon
};
#if DEBUG
this.AttachDevTools(DataContext.Cast<UIShellViewModel>().OpenDevTools);
#endif
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -0,0 +1,20 @@
using Avalonia.Input;
using Avalonia.Media;
using CommunityToolkit.Mvvm.ComponentModel;
namespace Volte.UI;
public partial class UIShellViewModel : ObservableObject
{
public KeyGesture OpenDevTools { get; init; }
public IImage Icon { get; init; }
[ObservableProperty]
private string _title = "Volte";
public UIShellViewModel()
{
}
}

View file

@ -1,73 +0,0 @@
using System;
using System.IO;
using System.Numerics;
using System.Text;
using Gommon;
using Silk.NET.SDL;
namespace Volte.UI;
public static class Extensions
{
#region Color
public static System.Drawing.Color AsColor(this Vector3 vec3)
=> System.Drawing.Color.FromArgb(255,
(int)(vec3.X.CoerceAtMost(1) * 255),
(int)(vec3.Y.CoerceAtMost(1) * 255),
(int)(vec3.Z.CoerceAtMost(1) * 255));
public static Vector3 AsVec3(this System.Drawing.Color color)
=> new(color.R / 255f, color.G / 255f, color.B / 255f);
public static Vector4 AsVec4(this System.Drawing.Color color)
=> new(color.R / 255f, color.G / 255f, color.B / 255f, color.A / 255f);
public static Vector3 AsVec3(this Color color)
=> new(color.R / 255f, color.G / 255f, color.B / 255f);
public static Vector4 AsVec4(this Color color)
=> new(color.R / 255f, color.G / 255f, color.B / 255f, color.A / 255f);
#endregion Color
}
public static class Buffers
{
public static void CopyBytesTo(this string str, Span<byte> span, Encoding encoding = null)
{
encoding ??= Encoding.UTF8;
var bytes = encoding.GetBytes(str);
bytes.CopyTo(span);
span[bytes.Length] = 0; //null terminator
}
public static unsafe void CopyBytesTo(this string str, byte* buffer, int bufferSize)
=> CopyBytesTo(str, new Span<byte>(buffer, bufferSize));
/**
* Reads the <see cref="Stream"/> into a <see cref="Span{T}"/> of bytes.
* Will throw by default if the read byte count is less than the <see cref="Stream"/>'s length.
* <param name="stream">The stream to read from.</param>
* <param name="throwOnUnderRead">Whether to throw if the read byte count is less than the stream's length.</param>
* <param name="fromStart">Whether to seek to the beginning of the stream before reading.</param>
*/
public static Span<byte> ToSpan(this Stream stream, bool throwOnUnderRead = true, bool fromStart = true)
{
if (fromStart)
stream.Seek(0, SeekOrigin.Begin);
var data = new byte[stream.Length];
// no using is a deliberate choice here; the user is responsible for disposing the stream,
// plus that might not be a desired side effect of calling this.
var read = new BinaryReader(stream).Read(data, 0, data.Length);
if (throwOnUnderRead && read != data.Length)
throw new InvalidDataException("Could not read all bytes from the stream.");
return data;
}
public static unsafe void CopyBytesFromString(byte* buffer, int bufferSize, string str)
=> str.CopyBytesTo(buffer, bufferSize);
}

View file

@ -0,0 +1,73 @@
using System.Collections.ObjectModel;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using FluentAvalonia.UI.Controls;
namespace Volte.UI.Helpers;
public partial class PageManager : ObservableObject
{
// ReSharper disable once InconsistentNaming
private static readonly Lazy<PageManager> _shared = new(() => new PageManager());
public static PageManager Shared => _shared.Value;
[ObservableProperty]
private PageData? _current = null;
private readonly Dictionary<Page, (int Index, bool IsFooter)> _lookup = [];
public ObservableCollection<PageData> Pages { get; } = [];
public ObservableCollection<PageData> FooterPages { get; } = [];
public PageData this[Page page] {
get {
var (index, isFooter) = _lookup[page];
return (isFooter ? FooterPages : Pages)[index];
}
}
public void Register(Page page, string title, object? content, Symbol icon, string? description = null, bool isDefault = false, bool isFooter = false)
{
var source = isFooter ? FooterPages : Pages;
_lookup[page] = (source.Count, isFooter);
source.Add(new PageData {
Title = title,
Content = content,
Description = description,
Icon = icon
});
if (isDefault) {
Focus(page);
}
}
public void Focus(Page page)
{
Current = this[page];
}
public T Get<T>(Page page) where T : ObservableObject
{
var (index, isFooter) = _lookup[page];
if ((isFooter ? FooterPages : Pages)[index].Content is UserControl { DataContext: T value }) {
return value;
}
throw new InvalidOperationException(
$"Invalid ViewModel type for '{page}'");
}
}
public enum Page
{
Home
}
public class PageData
{
public required string Title { get; set; }
public object? Content { get; set; }
public required Symbol Icon { get; set; }
public string? Description { get; set; }
}

View file

@ -1,74 +0,0 @@
using System;
using System.Numerics;
using ImGuiNET;
using Silk.NET.SDL;
namespace Volte.UI;
/**
* A class that contains all helper extension methods for ImGui.
* Named Gui instead of ImGui to avoid conflicts.
*/
public static class Gui
{
public static void SameLineText(ReadOnlySpan<char> text)
{
ImGui.Text(text);
ImGui.SameLine();
}
public static void SameLineText(ReadOnlySpan<char> text, Color color)
{
ImGui.TextColored(color.AsVec4(), text);
ImGui.SameLine();
}
public static void SameLineText(ReadOnlySpan<char> text, System.Drawing.Color color)
{
ImGui.TextColored(color.AsVec4(), text);
ImGui.SameLine();
}
public static void Text(ReadOnlySpan<char> text, Color color)
=> ImGui.TextColored(color.AsVec4(), text);
public static void Text(ReadOnlySpan<char> text, System.Drawing.Color color)
=> ImGui.TextColored(color.AsVec4(), text);
public static IDisposable PushValue(this ImGuiStyleVar styleVar, Vector2 value) => new ScopedStyleVar(styleVar, value);
public static IDisposable PushValue(this ImGuiStyleVar styleVar, float value) => new ScopedStyleVar(styleVar, value);
public static IDisposable PushValue(this ImGuiCol colorVar, Vector4 value) => new ScopedStyleColor(colorVar, value);
public static IDisposable PushValue(this ImGuiCol colorVar, Vector3 value) => new ScopedStyleColor(colorVar, value);
public static IDisposable PushValue(this ImGuiCol colorVar, Color value) => new ScopedStyleColor(colorVar, value.AsVec4());
public static IDisposable PushValue(this ImGuiCol colorVar, System.Drawing.Color value) =>
new ScopedStyleColor(colorVar, value.AsVec4());
public static IDisposable PushValue(this ImGuiCol colorVar, uint value) => new ScopedStyleColor(colorVar, value);
}
public struct ScopedStyleVar : IDisposable
{
internal ScopedStyleVar(ImGuiStyleVar styleVar, Vector2 value)
=> ImGui.PushStyleVar(styleVar, value);
internal ScopedStyleVar(ImGuiStyleVar styleVar, float value)
=> ImGui.PushStyleVar(styleVar, value);
void IDisposable.Dispose() => ImGui.PopStyleVar();
}
public struct ScopedStyleColor : IDisposable
{
internal ScopedStyleColor(ImGuiCol colorVar, Vector4 value)
=> ImGui.PushStyleColor(colorVar, value);
internal ScopedStyleColor(ImGuiCol colorVar, Vector3 value)
=> ImGui.PushStyleColor(colorVar, new Vector4(value, 1f));
internal ScopedStyleColor(ImGuiCol colorVar, uint value) =>
ImGui.PushStyleColor(colorVar, value);
void IDisposable.Dispose() => ImGui.PopStyleColor();
}

View file

@ -1,152 +0,0 @@
using System;
using System.Linq;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Gommon;
using ImGuiNET;
using Silk.NET.Core;
using Silk.NET.Input;
using Silk.NET.OpenGL;
using Silk.NET.OpenGL.Extensions.ImGui;
using Silk.NET.Windowing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Advanced;
using SixLabors.ImageSharp.PixelFormats;
using Color = Silk.NET.SDL.Color;
namespace Volte.UI;
#nullable enable
public sealed partial class UiManager : IDisposable
{
private bool _isActive;
private readonly IWindow _window;
private readonly Action<ImGuiIOPtr> _onConfigureIo;
private Image<Rgba32>? _windowIcon;
private ImGuiController? _controller;
private GL? _gl;
private IInputContext? _inputContext;
private void OnWindowLoad()
{
_gl = GL.GetApi(_window);
_inputContext = _window.CreateInput();
_controller = new ImGuiController(_gl, _window, _inputContext, onConfigureIO: () =>
{
var io = ImGui.GetIO();
io.ConfigFlags |= ImGuiConfigFlags.DockingEnable;
io.ConfigDockingWithShift = false;
unsafe
{
if (_theme != null)
SetColors(_theme);
else
ImGui.StyleColorsDark();
}
_onConfigureIo(io);
}
);
SetWindowIcon();
}
private void SetWindowIcon()
{
// shoutout https://github.com/dotnet/Silk.NET/blob/b079b28cd51ce447183cfedde0a85412b9b226ee/src/Lab/Experiments/BlankWindow/Program.cs#L82-L95
if (_windowIcon == null) return;
Span<byte> span = new byte[_windowIcon.GetPixelMemoryGroup().TotalLength * Unsafe.SizeOf<Rgba32>()];
var block = MemoryMarshal.Cast<byte, Rgba32>(span);
foreach (var memory in _windowIcon.GetPixelMemoryGroup())
{
memory.Span.CopyTo(block);
block = block[memory.Length..];
}
var rawIcon = new RawImage(_windowIcon.Width, _windowIcon.Height, span.ToArray());
_windowIcon.Dispose();
_windowIcon = null;
_window.SetWindowIcon(ref rawIcon);
}
public static unsafe void SetColors(ThemedColors* theme)
{
var style = ImGui.GetStyle();
style.GrabRounding = 4f;
style.FrameRounding = 6f;
style.WindowMenuButtonPosition = ImGuiDir.None;
style.FrameBorderSize = 1f;
style.TabBorderSize = 1f;
style.WindowTitleAlign = new Vector2(0.5f);
style.SeparatorTextBorderSize = 9f;
set(ImGuiCol.Text, theme->Gray800);
set(ImGuiCol.TextDisabled, theme->Gray500);
set(ImGuiCol.WindowBg, theme->Gray100);
set(ImGuiCol.ChildBg, Spectrum.Static.None);
set(ImGuiCol.PopupBg, theme->Gray50);
set(ImGuiCol.Border, theme->Gray300);
set(ImGuiCol.BorderShadow, Spectrum.Static.None);
set(ImGuiCol.FrameBg, theme->Gray75);
set(ImGuiCol.FrameBgHovered, theme->Gray50);
set(ImGuiCol.FrameBgActive, theme->Gray200);
set(ImGuiCol.TitleBg, theme->Gray300);
set(ImGuiCol.TitleBgActive, theme->Gray200);
set(ImGuiCol.TitleBgCollapsed, theme->Gray400);
set(ImGuiCol.TabUnfocusedActive, theme->Blue400);
set(ImGuiCol.MenuBarBg, theme->Gray100);
set(ImGuiCol.ScrollbarBg, theme->Gray100);
set(ImGuiCol.ScrollbarGrab, theme->Gray400);
set(ImGuiCol.ScrollbarGrabHovered, theme->Gray600);
set(ImGuiCol.ScrollbarGrabActive, theme->Gray700);
set(ImGuiCol.CheckMark, theme->Blue500);
set(ImGuiCol.SliderGrab, theme->Gray700);
set(ImGuiCol.SliderGrabActive, theme->Gray800);
set(ImGuiCol.Button, theme->Gray75);
set(ImGuiCol.ButtonHovered, theme->Gray50);
set(ImGuiCol.ButtonActive, theme->Gray200);
set(ImGuiCol.Header, theme->Blue400);
set(ImGuiCol.HeaderHovered, theme->Blue500);
set(ImGuiCol.HeaderActive, theme->Blue600);
set(ImGuiCol.Separator, theme->Gray400);
set(ImGuiCol.SeparatorHovered, theme->Gray600);
set(ImGuiCol.SeparatorActive, theme->Gray700);
set(ImGuiCol.ResizeGrip, theme->Gray400);
set(ImGuiCol.ResizeGripHovered, theme->Gray600);
set(ImGuiCol.ResizeGripActive, theme->Gray700);
set(ImGuiCol.PlotLines, theme->Blue400);
set(ImGuiCol.PlotLinesHovered, theme->Blue600);
set(ImGuiCol.PlotHistogram, theme->Blue400);
set(ImGuiCol.PlotHistogramHovered, theme->Blue600);
setVec(ImGuiCol.TextSelectedBg, ImGui.ColorConvertU32ToFloat4((colorValue(theme->Blue400) & 0x00FFFFFF) | 0x33000000));
setVec(ImGuiCol.DragDropTarget, new Vector4(1.00f, 1.00f, 0.00f, 0.90f));
setVec(ImGuiCol.NavHighlight, ImGui.ColorConvertU32ToFloat4((colorValue(theme->Gray900) & 0x00FFFFFF) | 0x0A000000));
setVec(ImGuiCol.NavWindowingHighlight, new Vector4(1.00f, 1.00f, 1.00f, 0.70f));
setVec(ImGuiCol.NavWindowingDimBg, new Vector4(0.80f, 0.80f, 0.80f, 0.20f));
setVec(ImGuiCol.ModalWindowDimBg, new Vector4(0.20f, 0.20f, 0.20f, 0.35f));
return;
void set(ImGuiCol colorVar, Color color)
=> setVec(colorVar, new Vector4(color.R / 255f, color.G / 255f, color.B / 255f, 1f));
void setVec(ImGuiCol colorVar, Vector4 colorVec)
=> style.Colors[(int)colorVar] = colorVec;
uint colorValue(Color color) => ((uint)color.R << 16)
| ((uint)color.G << 8)
| color.B;
}
}

View file

@ -1,187 +0,0 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Gommon;
using ImGuiNET;
using Silk.NET.Windowing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
#nullable enable
namespace Volte.UI;
// adapted from https://github.com/dotnet/Silk.NET/blob/main/examples/CSharp/OpenGL%20Demos/ImGui/Program.cs
public sealed partial class UiManager
{
public readonly ConcurrentQueue<Task> TaskQueue = new();
private readonly List<UiView> _views = [];
private readonly unsafe ThemedColors* _theme;
private int CurrentViewIdx { get; set; }
private void SetView(int viewIndex) =>
CurrentViewIdx = viewIndex.CoerceAtLeast(0).CoerceAtMost(_views.Count - 1);
public UiView CurrentView => _views[CurrentViewIdx];
private unsafe UiManager(CreateParams @params)
{
_theme = @params.Theme;
_window = Window.Create(@params.WOptions);
_onConfigureIo = @params.OnConfigureIo;
_windowIcon = @params.WindowIcon;
_window.Load += OnWindowLoad;
_window.Render += OnWindowRender;
_window.FramebufferResize += sz => _gl?.Viewport(sz);
_window.Closing += () =>
{
_isActive = false;
_controller?.Dispose();
_inputContext?.Dispose();
_gl?.Dispose();
};
}
public void Run()
{
_isActive = true;
Lambda
.Repeat(async () =>
{
await Task.Delay(
125); // tasks dont get added too often; so there's no need to run the polling below it as fast as possible.
if (TaskQueue.TryDequeue(out var task))
await task.ConfigureAwait(false);
})
.While(() => _isActive)
.Finally(() => TaskQueue.Clear())
.Async();
_window.Run();
}
private const ImGuiWindowFlags DockSpaceFlags =
ImGuiWindowFlags.NoDocking | ImGuiWindowFlags.NoTitleBar |
ImGuiWindowFlags.NoCollapse | ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove |
ImGuiWindowFlags.NoBringToFrontOnFocus | ImGuiWindowFlags.NoNavFocus;
private void OnWindowRender(double delta)
{
if (_window.WindowState == WindowState.Minimized) return;
_controller?.Update((float)delta);
// shoutout https://gist.github.com/moebiussurfing/8dbc7fef5964adcd29428943b78e45d2
// for showing me how to properly setup dock space
var currView = CurrentView;
var viewport = ImGui.GetMainViewport();
ImGui.SetNextWindowPos(viewport.WorkPos);
ImGui.SetNextWindowSize(viewport.WorkSize);
ImGui.SetNextWindowViewport(viewport.ID);
using (ImGuiStyleVar.WindowRounding.PushValue(0f))
using (ImGuiStyleVar.WindowBorderSize.PushValue(0f))
ImGui.Begin("Dock Space",
currView.MainMenuBar != null
? DockSpaceFlags | ImGuiWindowFlags.MenuBar
: DockSpaceFlags
);
ImGui.DockSpace(ImGui.GetID("DockSpace"), Vector2.Zero);
if (currView.MainMenuBar is { } menuBar)
if (ImGui.BeginMenuBar())
{
menuBar(delta);
ImGui.EndMenuBar();
}
currView.RenderInternal(delta);
ImGui.End();
_controller?.Render();
}
public void Dispose() => _window.Dispose();
public static UiManager? Instance { get; private set; }
public readonly struct CreateParams
{
public WindowOptions WOptions { get; init; }
public Action<ImGuiIOPtr> OnConfigureIo { get; init; }
public Image<Rgba32>? WindowIcon { get; init; }
public unsafe ThemedColors* Theme { get; init; }
}
public static bool TryCreateUi(CreateParams createParams, out Exception? error)
{
if (Instance is not null)
{
error = new InvalidOperationException("UI is already open.");
return false;
}
try
{
Instance = new UiManager(createParams);
}
catch (Exception e)
{
error = e;
return false;
}
error = null;
return true;
}
public static void StartThread(string threadName)
{
// declared as illegal code by the Silk God (Main thread isn't the controller of the Window)
new Thread(() =>
{
Instance?.Run(); //returns when UI is closed
Instance?.Dispose();
Instance = null;
}) { Name = threadName }.Start();
}
public static void AddView(UiView view) => Instance!._views.Add(view);
public static unsafe void LoadFontFromStream(Stream stream, string fontName, float fontSize)
{
var fontData = stream.ToSpan();
fixed (byte* fontDataPtr = fontData)
{
var fontConfig = ImGuiNative.ImFontConfig_ImFontConfig();
fontConfig->FontData = fontDataPtr;
fontConfig->FontDataSize = fontData.Length;
fontConfig->SizePixels = fontSize;
Buffers.CopyBytesFromString(fontConfig->Name, 40, fontName); //40 is the size of the name buffer in ImFontConfig
ImGuiNative.ImFontAtlas_AddFont(ImGui.GetIO().Fonts, fontConfig);
}
}
}

27
src/UI/Program.cs Normal file
View file

@ -0,0 +1,27 @@
using Avalonia;
using Volte.Helpers;
namespace Volte.UI;
public class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static int Main(string[] args)
{
if (!UnixHelper.TryParseNamedArguments(args, out var output) && output.Error is not InvalidOperationException)
Logger.Error(output.Error);
return BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
private static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace(Avalonia.Logging.LogEventLevel.Debug, "TabView");
}

4
src/UI/README.md Normal file
View file

@ -0,0 +1,4 @@
# Volte's Avalonia UI
This UI built with Avalonia and FluentAvalonia, and is a direct modification of the UI of the [Tears of the Kingdom Mod Merger](https://github.com/TKMM-Team/Tkmm/).<br/>
Obviously Volte has no mod merging functionality, so what it's using is basically just TKMM's styling & instrumentation code.

View file

@ -1,254 +0,0 @@
using Silk.NET.SDL;
namespace Volte.UI;
// https://github.com/adobe/imgui/blob/master/imgui_spectrum.h
public static class Spectrum
{
public static unsafe ThemedColors* Dark
{
get
{
fixed (DarkThemedColors* ptr = &DarkThemedColors.Instance)
return (ThemedColors*)ptr;
}
}
public static unsafe ThemedColors* Light
{
get
{
fixed (LightThemedColors* ptr = &LightThemedColors.Instance)
return (ThemedColors*)ptr;
}
}
public class DarkThemedColors : ThemedColors
{
internal static DarkThemedColors Instance = new();
internal override Color Gray50 => Color(0x252525);
internal override Color Gray75 => Color(0x2F2F2F);
internal override Color Gray100 => Color(0x323232);
internal override Color Gray200 => Color(0x393939);
internal override Color Gray300 => Color(0x3E3E3E);
internal override Color Gray400 => Color(0x4D4D4D);
internal override Color Gray500 => Color(0x5C5C5C);
internal override Color Gray600 => Color(0x7B7B7B);
internal override Color Gray700 => Color(0x999999);
internal override Color Gray800 => Color(0xCDCDCD);
internal override Color Gray900 => Color(0xFFFFFF);
internal override Color Blue400 => Color(0x2680EB);
internal override Color Blue500 => Color(0x378EF0);
internal override Color Blue600 => Color(0x4B9CF5);
internal override Color Blue700 => Color(0x5AA9FA);
internal override Color Red400 => Color(0xE34850);
internal override Color Red500 => Color(0xEC5B62);
internal override Color Red600 => Color(0xF76D74);
internal override Color Red700 => Color(0xFF7B82);
internal override Color Orange400 => Color(0xE68619);
internal override Color Orange500 => Color(0xF29423);
internal override Color Orange600 => Color(0xF9A43F);
internal override Color Orange700 => Color(0xFFB55B);
internal override Color Green400 => Color(0x2D9D78);
internal override Color Green500 => Color(0x33AB84);
internal override Color Green600 => Color(0x39B990);
internal override Color Green700 => Color(0x3FC89C);
internal override Color Indigo400 => Color(0x6767EC);
internal override Color Indigo500 => Color(0x7575F1);
internal override Color Indigo600 => Color(0x8282F6);
internal override Color Indigo700 => Color(0x9090FA);
internal override Color Celery400 => Color(0x44B556);
internal override Color Celery500 => Color(0x4BC35F);
internal override Color Celery600 => Color(0x51D267);
internal override Color Celery700 => Color(0x58E06F);
internal override Color Magenta400 => Color(0xD83790);
internal override Color Magenta500 => Color(0xE2499D);
internal override Color Magenta600 => Color(0xEC5AAA);
internal override Color Magenta700 => Color(0xF56BB7);
internal override Color Yellow400 => Color(0xDFBF00);
internal override Color Yellow500 => Color(0xEDCC00);
internal override Color Yellow600 => Color(0xFAD900);
internal override Color Yellow700 => Color(0xFFE22E);
internal override Color Fuchsia400 => Color(0xC038CC);
internal override Color Fuchsia500 => Color(0xCF3EDC);
internal override Color Fuchsia600 => Color(0xD951E5);
internal override Color Fuchsia700 => Color(0xE366EF);
internal override Color Seafoam400 => Color(0x1B959A);
internal override Color Seafoam500 => Color(0x20A3A8);
internal override Color Seafoam600 => Color(0x23B2B8);
internal override Color Seafoam700 => Color(0x26C0C7);
internal override Color Chartreuse400 => Color(0x85D044);
internal override Color Chartreuse500 => Color(0x8EDE49);
internal override Color Chartreuse600 => Color(0x9BEC54);
internal override Color Chartreuse700 => Color(0xA3F858);
internal override Color Purple400 => Color(0x9256D9);
internal override Color Purple500 => Color(0x9D64E1);
internal override Color Purple600 => Color(0xA873E9);
internal override Color Purple700 => Color(0xB483F0);
}
public class LightThemedColors : ThemedColors
{
internal static LightThemedColors Instance = new();
internal override Color Gray50 => Color(0xFFFFFF);
internal override Color Gray75 => Color(0xFAFAFA);
internal override Color Gray100 => Color(0xF5F5F5);
internal override Color Gray200 => Color(0xEAEAEA);
internal override Color Gray300 => Color(0xE1E1E1);
internal override Color Gray400 => Color(0xCACACA);
internal override Color Gray500 => Color(0xB3B3B3);
internal override Color Gray600 => Color(0x8E8E8E);
internal override Color Gray700 => Color(0x707070);
internal override Color Gray800 => Color(0x4B4B4B);
internal override Color Gray900 => Color(0x2C2C2C);
internal override Color Blue400 => Color(0x2680EB);
internal override Color Blue500 => Color(0x1473E6);
internal override Color Blue600 => Color(0x0D66D0);
internal override Color Blue700 => Color(0x095ABA);
internal override Color Red400 => Color(0xE34850);
internal override Color Red500 => Color(0xD7373F);
internal override Color Red600 => Color(0xC9252D);
internal override Color Red700 => Color(0xBB121A);
internal override Color Orange400 => Color(0xE68619);
internal override Color Orange500 => Color(0xDA7B11);
internal override Color Orange600 => Color(0xCB6F10);
internal override Color Orange700 => Color(0xBD640D);
internal override Color Green400 => Color(0x2D9D78);
internal override Color Green500 => Color(0x268E6C);
internal override Color Green600 => Color(0x12805C);
internal override Color Green700 => Color(0x107154);
internal override Color Indigo400 => Color(0x6767EC);
internal override Color Indigo500 => Color(0x5C5CE0);
internal override Color Indigo600 => Color(0x5151D3);
internal override Color Indigo700 => Color(0x4646C6);
internal override Color Celery400 => Color(0x44B556);
internal override Color Celery500 => Color(0x3DA74E);
internal override Color Celery600 => Color(0x379947);
internal override Color Celery700 => Color(0x318B40);
internal override Color Magenta400 => Color(0xD83790);
internal override Color Magenta500 => Color(0xCE2783);
internal override Color Magenta600 => Color(0xBC1C74);
internal override Color Magenta700 => Color(0xAE0E66);
internal override Color Yellow400 => Color(0xDFBF00);
internal override Color Yellow500 => Color(0xD2B200);
internal override Color Yellow600 => Color(0xC4A600);
internal override Color Yellow700 => Color(0xB79900);
internal override Color Fuchsia400 => Color(0xC038CC);
internal override Color Fuchsia500 => Color(0xB130BD);
internal override Color Fuchsia600 => Color(0xA228AD);
internal override Color Fuchsia700 => Color(0x93219E);
internal override Color Seafoam400 => Color(0x1B959A);
internal override Color Seafoam500 => Color(0x16878C);
internal override Color Seafoam600 => Color(0x0F797D);
internal override Color Seafoam700 => Color(0x096C6F);
internal override Color Chartreuse400 => Color(0x85D044);
internal override Color Chartreuse500 => Color(0x7CC33F);
internal override Color Chartreuse600 => Color(0x73B53A);
internal override Color Chartreuse700 => Color(0x6AA834);
internal override Color Purple400 => Color(0x9256D9);
internal override Color Purple500 => Color(0x864CCC);
internal override Color Purple600 => Color(0x7A42BF);
internal override Color Purple700 => Color(0x6F38B1);
}
public static class Static
{
internal static Color None = Color(0x000000);
internal static Color Gray200 = Color(0xF4F4F4);
internal static Color Gray300 = Color(0xEAEAEA);
internal static Color Gray400 = Color(0xD3D3D3);
internal static Color Gray500 = Color(0xBCBCBC);
internal static Color Gray600 = Color(0x959595);
internal static Color Gray700 = Color(0x767676);
internal static Color Gray800 = Color(0x505050);
internal static Color Gray900 = Color(0x323232);
internal static Color Blue400 = Color(0x378EF0);
internal static Color Blue500 = Color(0x2680EB);
internal static Color Blue600 = Color(0x1473E6);
internal static Color Blue700 = Color(0x0D66D0);
internal static Color Red400 = Color(0xEC5B62);
internal static Color Red500 = Color(0xE34850);
internal static Color Red600 = Color(0xD7373F);
internal static Color Red700 = Color(0xC9252D);
internal static Color Orange400 = Color(0xF29423);
internal static Color Orange500 = Color(0xE68619);
internal static Color Orange600 = Color(0xDA7B11);
internal static Color Orange700 = Color(0xCB6F10);
internal static Color Green400 = Color(0x33AB84);
internal static Color Green500 = Color(0x2D9D78);
internal static Color Green600 = Color(0x268E6C);
internal static Color Green700 = Color(0x12805C);
}
private static Color Color(uint raw)
{
var r = (raw >> 16) & 0xFF;
var g = (raw >> 8) & 0xFF;
var b = (raw >> 0) & 0xFF;
return new Color((byte)r, (byte)g, (byte)b);
}
}
public abstract class ThemedColors
{
internal abstract Color Gray50 { get; }
internal abstract Color Gray75 { get; }
internal abstract Color Gray100 { get; }
internal abstract Color Gray200 { get; }
internal abstract Color Gray300 { get; }
internal abstract Color Gray400 { get; }
internal abstract Color Gray500 { get; }
internal abstract Color Gray600 { get; }
internal abstract Color Gray700 { get; }
internal abstract Color Gray800 { get; }
internal abstract Color Gray900 { get; }
internal abstract Color Blue400 { get; }
internal abstract Color Blue500 { get; }
internal abstract Color Blue600 { get; }
internal abstract Color Blue700 { get; }
internal abstract Color Red400 { get; }
internal abstract Color Red500 { get; }
internal abstract Color Red600 { get; }
internal abstract Color Red700 { get; }
internal abstract Color Orange400 { get; }
internal abstract Color Orange500 { get; }
internal abstract Color Orange600 { get; }
internal abstract Color Orange700 { get; }
internal abstract Color Green400 { get; }
internal abstract Color Green500 { get; }
internal abstract Color Green600 { get; }
internal abstract Color Green700 { get; }
internal abstract Color Indigo400 { get; }
internal abstract Color Indigo500 { get; }
internal abstract Color Indigo600 { get; }
internal abstract Color Indigo700 { get; }
internal abstract Color Celery400 { get; }
internal abstract Color Celery500 { get; }
internal abstract Color Celery600 { get; }
internal abstract Color Celery700 { get; }
internal abstract Color Magenta400 { get; }
internal abstract Color Magenta500 { get; }
internal abstract Color Magenta600 { get; }
internal abstract Color Magenta700 { get; }
internal abstract Color Yellow400 { get; }
internal abstract Color Yellow500 { get; }
internal abstract Color Yellow600 { get; }
internal abstract Color Yellow700 { get; }
internal abstract Color Fuchsia400 { get; }
internal abstract Color Fuchsia500 { get; }
internal abstract Color Fuchsia600 { get; }
internal abstract Color Fuchsia700 { get; }
internal abstract Color Seafoam400 { get; }
internal abstract Color Seafoam500 { get; }
internal abstract Color Seafoam600 { get; }
internal abstract Color Seafoam700 { get; }
internal abstract Color Chartreuse400 { get; }
internal abstract Color Chartreuse500 { get; }
internal abstract Color Chartreuse600 { get; }
internal abstract Color Chartreuse700 { get; }
internal abstract Color Purple400 { get; }
internal abstract Color Purple500 { get; }
internal abstract Color Purple600 { get; }
internal abstract Color Purple700 { get; }
}

View file

@ -1,76 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Numerics;
using System.Threading.Tasks;
using ImGuiNET;
using Silk.NET.Input;
using Silk.NET.SDL;
namespace Volte.UI;
[SuppressMessage("ReSharper", "VirtualMemberNeverOverridden.Global")] //this is an API
public abstract class UiView
{
private readonly List<Action<double>> _panels = [];
public Action<double> MainMenuBar { get; protected init; }
protected static ImGuiIOPtr Io => ImGui.GetIO();
public static bool IsKeyPressed(Key key)
=> Io.KeysDown[(int)key];
public static bool IsMouseButtonPressed(MouseButton mb)
=> Io.MouseDown[(int)mb];
public static bool AllKeysPressed(params Key[] keys)
{
if (keys.Length == 1)
return IsKeyPressed(keys[0]);
var io = Io;
return keys.Select(key => io.KeysDown[(int)key])
.All(x => x);
}
public static bool AllMouseButtonsPressed(params MouseButton[] mouseButtons)
{
if (mouseButtons.Length == 1)
return Io.MouseDown[(int)mouseButtons[0]];
var io = Io;
return mouseButtons.Select(mb => io.MouseDown[(int)mb])
.All(x => x);
}
/**
* Override this function for custom one-off rendering.
* The return value determines whether your <see cref="UiView"/>'s defined panels will be rendered.
* (false = no render, true = render)
*/
protected virtual bool Render(double _) => true;
internal void RenderInternal(double delta)
{
if (!Render(delta)) return;
foreach (var renderPanel in _panels)
renderPanel(delta);
}
protected static void Await(Func<Task> task) => Await(task());
protected static void Await(Task task) => UiManager.Instance!.TaskQueue.Enqueue(task);
protected void Panel(string label, Action<double> render) => _panels.Add(delta =>
{
if (ImGui.Begin(label))
{
render(delta);
ImGui.End();
}
});
protected void Panel(Action<double> render) => _panels.Add(render);
}

View file

@ -1,28 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<Authors>GreemDev</Authors>
<Company>Polyhaze</Company>
<RepositoryUrl>https://github.com/Polyhaze/Volte</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Version>1.0.1</Version>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<NoWarn>$(NoWarn);CS8500</NoWarn>
<Description>The mini abstraction used for the UI of Volte. https://github.com/Polyhaze/Volte</Description>
<PackageReleaseNotes>
Themes are no longer passed by ref, and instead are passed by pointer.
Layers are now added AFTER UiManager is initialized and the instance is set.
TryCreateUi no longer automatically starts the UI thread, for you to add layers &amp; do any custom ImGui setup before starting the thread.
</PackageReleaseNotes>
<PackageTags>Silk.NET;Silk;Silk.NET.Windowing;Silk.NET.OpenGL;Silk.NET.OpenGL.Extensions.ImGui;Silk.NET.Input;ImGui;UI;GUI</PackageTags>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Gommon" Version="2.6.4" />
<PackageReference Include="Silk.NET.Input" Version="2.21.0" />
<PackageReference Include="Silk.NET.OpenGL" Version="2.21.0" />
<PackageReference Include="Silk.NET.OpenGL.Extensions.ImGui" Version="2.21.0" />
<PackageReference Include="Silk.NET.Windowing" Version="2.21.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.1.0" />
<PackageReference Include="ConfigFactory.Avalonia" Version="0.4.2" />
<PackageReference Include="Avalonia" Version="11.1.0"/>
<PackageReference Include="Avalonia.Desktop" Version="11.1.0"/>
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.1.0"/>
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.1.0"/>
<PackageReference Include="FluentAvaloniaUI" Version="2.0.5" />
<ProjectReference Include="../Bot/Volte.csproj"/>
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="..\Bot\Resources\Volte.ico" Link="Assets\icon.ico" />
</ItemGroup>
<ItemGroup>
<Compile Update="Avalonia\App.axaml.cs">
<DependentUpon>App.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
<Compile Update="Avalonia\UIShellView.axaml.cs">
<DependentUpon>MainWindow.axaml</DependentUpon>
<SubType>Code</SubType>
</Compile>
</ItemGroup>
</Project>

View file

@ -1 +0,0 @@
dotnet publish -r Release