Added a setting to change the size of the interface (#1136)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Paul KoudelkaandThorsten Sommer authored and GitHub committed 2026-10-30 20:01:57 +01:00
1 parent c2b62c6dbd
commit e0337a99bc
27 files changed
+1742 -27

No files matched your search

@@ -0,0 +1,13 @@
@using AIStudio.Settings
@using AIStudio.Settings.DataModel
@inherits MSGComponentBase
@*
Another screen may offer other sizes, and a select that gets its new value before the item of
that value exists keeps showing the old one. The key builds the select anew for every screen:
*@
<ConfigurationSelect @key="@this.ScreenKey" OptionDescription="@T("Interface size on this screen")" SelectedValue="@(() => this.SizeOnScreen)" Data="@ConfigurationSelectDataFactory.GetInterfaceSizeData(this.OffersAppDefault)" SelectionUpdateAsync="@this.UpdateSize" Disabled="@(() => this.MeasuredScreen is null)" OptionHelp="@this.SizeHelp"/>
@if (this.MeasuredScreen is { } screen && this.SizeOnScreen is InterfaceSize.CUSTOM)
{
<ConfigurationSlider T="int" OptionDescription="@T("Custom interface size on this screen")" Min="@screen.MinPercent" Max="@screen.MaxPercent" Step="5" Unit="%" Value="@(() => this.ScalePercent(screen))" ValueUpdate="@this.UpdateScalePercent" Immediate="@false" OptionHelp="@this.ScaleHelp(screen)"/>
}
@@ -0,0 +1,157 @@
using AIStudio.Settings.DataModel;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Rust;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// The interface size of the screen AI Studio shows on: a selection of the size, and a slider for the
/// scale of the custom size.
/// </summary>
/// <remarks>
/// Always shows and changes the screen the window is on right now. The runtime reports that screen
/// when the option opens, and again whenever the size on the screen changed, e.g., because the window
/// moved to another screen. The runtime applies the sizes itself; InterfaceSizeService hands them over
/// whenever the settings change.
/// </remarks>
public partial class ConfigurationInterfaceSize : MSGComponentBase
{
[Inject]
private RustService RustService { get; init; } = null!;
/// <summary>
/// The screen AI Studio shows on, as the runtime reported it last: when the option opened, and
/// again whenever the size on the screen changed, e.g., because the window moved to another
/// screen. Null when the runtime could not measure the screen.
/// </summary>
private InterfaceSizeResponse? interfaceSizeOnScreen;
/// <summary>
/// The screen whose size the option shows and changes, or null when it is unknown.
/// </summary>
private InterfaceSizeResponse? MeasuredScreen => this.interfaceSizeOnScreen is { ScreenKey.Length: > 0 } screen ? screen : null;
/// <summary>
/// The key of the screen whose size the option shows, or an empty one when the screen is unknown.
/// </summary>
private string ScreenKey => this.MeasuredScreen?.ScreenKey ?? string.Empty;
/// <summary>
/// Whether this screen offers the app default. It only differs from the system scaling on the
/// screen of a laptop, which is also the only screen it is the default of.
/// </summary>
private bool OffersAppDefault => this.MeasuredScreen?.DefaultSize is InterfaceSize.APP_DEFAULT;
/// <summary>
/// The size on this screen, as the selection shows it: the one the user chose, or else the
/// default size of the screen.
/// </summary>
private InterfaceSize SizeOnScreen
{
get
{
if (this.MeasuredScreen is not { } screen)
return InterfaceSize.SYSTEM_SCALING;
var size = this.SettingsManager.ConfigurationData.App.GetInterfaceScreenSize(screen.ScreenKey)?.Size ?? screen.DefaultSize;
// Where the selection does not offer the app default, the app default shows the system scaling:
return size is InterfaceSize.APP_DEFAULT && !this.OffersAppDefault ? InterfaceSize.SYSTEM_SCALING : size;
}
}
private string SizeHelp => this.MeasuredScreen switch
{
null => T("AI Studio cannot measure this screen right now, so you cannot set the interface size here."),
{ } screen when this.OffersAppDefault => string.Format(I18N.I.Culture, T("Applies to this screen only ({0}). The app default shows AI Studio in the same size on every laptop. System scaling follows your operating system."), this.DescribeScreen(screen)),
{ } screen => string.Format(I18N.I.Culture, T("Applies to this screen only ({0}). System scaling follows your operating system."), this.DescribeScreen(screen)),
};
/// <summary>
/// The custom scale of the given screen, as the slider shows it: the one the user set, or else the
/// one the screen shows with. Within the range of the screen, as the runtime applies it.
/// </summary>
private int ScalePercent(InterfaceSizeResponse screen)
{
var percent = this.SettingsManager.ConfigurationData.App.GetInterfaceScreenSize(screen.ScreenKey)?.CustomPercent ?? screen.CurrentPercent;
return Math.Clamp(percent, screen.MinPercent, screen.MaxPercent);
}
/// <summary>
/// The help of the slider: the range of the given screen, and what its default size comes to for
/// a comparison, since the slider starts there when the user comes from the default.
/// </summary>
private string ScaleHelp(InterfaceSizeResponse screen) => this.OffersAppDefault
? string.Format(I18N.I.Culture, T("You can choose from {0} % to {1} %. The app default on this screen is {2} %."), screen.MinPercent, screen.MaxPercent, screen.AppDefaultPercent)
: string.Format(I18N.I.Culture, T("You can choose from {0} % to {1} %. The system scaling on this screen is {2} %."), screen.MinPercent, screen.MaxPercent, screen.SystemPercent);
private string DescribeScreen(InterfaceSizeResponse screen) => screen.ScreenDiagonalInches is { } diagonal
? string.Format(I18N.I.Culture, T("{0} × {1} pixels, {2:0.#} inches"), screen.ScreenWidth, screen.ScreenHeight, diagonal)
: string.Format(I18N.I.Culture, T("{0} × {1} pixels"), screen.ScreenWidth, screen.ScreenHeight);
private void UpdateScalePercent(int percent)
{
if (this.MeasuredScreen is { } screen)
this.SettingsManager.ConfigurationData.App.RememberInterfaceScreenSize(new(screen.ScreenKey, InterfaceSize.CUSTOM, percent));
}
/// <summary>
/// Changes the size on the screen the window shows on.
/// </summary>
/// <remarks>
/// The screen is asked from the runtime right now rather than taken from its last report, in
/// case the window has only just moved. A screen whose custom scale the user set before goes
/// back to it with the custom size. Any other starts from the scale AI Studio shows with right
/// now, so that nothing changes on the screen until the user moves the slider; only a scale
/// beyond the range of the screen moves to its nearest end.
/// </remarks>
private async Task UpdateSize(InterfaceSize size)
{
this.interfaceSizeOnScreen = await this.RustService.GetInterfaceSize() ?? this.interfaceSizeOnScreen;
if (this.MeasuredScreen is not { } screen)
return;
var app = this.SettingsManager.ConfigurationData.App;
var customPercent = app.GetInterfaceScreenSize(screen.ScreenKey)?.CustomPercent;
if (size is InterfaceSize.CUSTOM)
customPercent ??= Math.Clamp(screen.CurrentPercent, screen.MinPercent, screen.MaxPercent);
app.RememberInterfaceScreenSize(new(screen.ScreenKey, size, customPercent));
}
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED, Event.TAURI_EVENT_RECEIVED ]);
await base.OnInitializedAsync();
this.interfaceSizeOnScreen = await this.RustService.GetInterfaceSize();
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
// Another size may call for the slider, or no longer:
case Event.CONFIGURATION_CHANGED:
this.StateHasChanged();
break;
//
// The window moved to another screen, or the size on the screen changed otherwise. The
// option has to show and change the screen the window is on now:
//
case Event.TAURI_EVENT_RECEIVED when data is TauriEvent { EventType: TauriEventType.INTERFACE_SIZE_CHANGED }:
this.interfaceSizeOnScreen = await this.RustService.GetInterfaceSize() ?? this.interfaceSizeOnScreen;
this.StateHasChanged();
break;
}
await base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
}
#endregion
}
@@ -1,6 +1,8 @@
@using Microsoft.AspNetCore.Components.Web
@typeparam T
@inherits ConfigurationBaseCore
<MudSlider T="@T" Size="Size.Medium" Value="@this.Value()" ValueChanged="@this.OptionChanged" Min="@this.Min" Max="@this.Max" Step="@this.Step" Immediate="@true" Disabled="@this.IsDisabled" Class="mb-1">
@this.Value() @this.Unit
@* The input event bubbles up from the range input inside, also while the value waits for the release: *@
<MudSlider T="@T" Size="Size.Medium" Value="@this.Value()" ValueChanged="@this.OptionChanged" Min="@this.Min" Max="@this.Max" Step="@this.Step" Immediate="@this.Immediate" Disabled="@this.IsDisabled" Class="mb-1" @oninput="@this.ShowDraggedValue">
@(this.draggedValue ?? this.Value()) @this.Unit
</MudSlider>
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Numerics;
using Microsoft.AspNetCore.Components;
@@ -42,6 +43,22 @@ public partial class ConfigurationSlider<T> : ConfigurationBaseCore where T : st
[Parameter]
public Action<T> ValueUpdate { get; set; } = _ => { };
/// <summary>
/// Whether the option changes while the user drags the slider, or only once they release it.
/// </summary>
/// <remarks>
/// Defaults to true. An option which changes the layout of the page, such as the interface size,
/// should wait for the release: a page which changes under the pointer moves the slider away from
/// it. The slider shows the value it is dragged to either way.
/// </remarks>
[Parameter]
public bool Immediate { get; set; } = true;
/// <summary>
/// The value the user drags the slider to, while the option waits for the release, see Immediate.
/// </summary>
private T? draggedValue;
#region Overrides of ConfigurationBase
/// <inheritdoc />
@@ -72,10 +89,24 @@ public partial class ConfigurationSlider<T> : ConfigurationBaseCore where T : st
private async Task OptionChanged(T updatedValue)
{
this.draggedValue = null;
this.ValueUpdate(updatedValue);
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
/// <summary>
/// Shows the value the user drags the slider to, while the option waits for the release.
/// </summary>
private void ShowDraggedValue(ChangeEventArgs args)
{
if (this.Immediate)
return;
// The range input reports its value with a dot as decimal separator, whatever the language:
if (T.TryParse(args.Value?.ToString(), CultureInfo.InvariantCulture, out var value))
this.draggedValue = value;
}
private async Task EnsureMinMax()
{
@@ -12,6 +12,7 @@
}
<ConfigurationSelect OptionDescription="@T("Color theme")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreferredTheme)" Data="@ConfigurationSelectDataFactory.GetThemesData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.PreferredTheme = selectedValue)" OptionHelp="@T("Choose the color theme that best suits for you.")"/>
<ConfigurationInterfaceSize/>
<ConfigurationOption OptionDescription="@T("Save energy?")" LabelOn="@T("Energy saving is enabled")" LabelOff="@T("Energy saving is disabled")" State="@(() => this.SettingsManager.ConfigurationData.App.IsSavingEnergy)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.IsSavingEnergy = updatedState)" OptionHelp="@T("When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.IsSavingEnergy, out var meta) && meta.IsLocked"/>
<ConfigurationOption OptionDescription="@T("Enable spellchecking?")" LabelOn="@T("Spellchecking is enabled")" LabelOff="@T("Spellchecking is disabled")" State="@(() => this.SettingsManager.ConfigurationData.App.EnableSpellchecking)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.EnableSpellchecking = updatedState)" OptionHelp="@T("When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections.")"/>
<ConfigurationOption OptionDescription="@T("Close messages about exported files automatically?")" LabelOn="@T("The message closes after a few seconds")" LabelOff="@T("The message stays until you close it")" State="@(() => this.SettingsManager.ConfigurationData.App.CloseExportMessagesAutomatically)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.CloseExportMessagesAutomatically = updatedState)" OptionHelp="@T("After you export a file, for example, an answer or a presentation, a message offers to show the file in your file manager or to open it. When enabled, this message closes after a few seconds, like other messages. When disabled, it stays until you close it.")"/>