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,39 @@
using AIStudio.Settings.DataModel;
namespace AIStudio.Tools.Rust;
/// <summary>
/// What the runtime reports about the interface size on the screen AI Studio shows on.
/// </summary>
/// <remarks>
/// All percentages count like the scaling of the operating system: device pixels per pixel of the app.
/// </remarks>
/// <param name="Success">Whether the runtime could measure the screen.</param>
/// <param name="ErrorMessage">What went wrong, when it could not.</param>
/// <param name="Zoom">The zoom of the webview: 1.0 is the scaling of the system.</param>
/// <param name="Size">The size on this screen: the one the user chose for it, or else its default size.</param>
/// <param name="DefaultSize">The size of this screen while the user chose none for it: the app default on the screen of a laptop, the scaling of the system on any other.</param>
/// <param name="ScreenKey">What the size of this screen is stored by: its resolution and its physical size.</param>
/// <param name="ScreenWidth">The width of the screen in device pixels.</param>
/// <param name="ScreenHeight">The height of the screen in device pixels.</param>
/// <param name="ScreenDiagonalInches">The diagonal of the screen in inches, or null when the screen does not report its size.</param>
/// <param name="SystemPercent">The scaling of the system on this screen.</param>
/// <param name="CurrentPercent">The scale AI Studio shows with.</param>
/// <param name="AppDefaultPercent">The scale the app default gives this screen, which is also where its custom scale starts.</param>
/// <param name="MinPercent">The smallest custom scale this screen allows.</param>
/// <param name="MaxPercent">The largest custom scale this screen allows.</param>
public sealed record InterfaceSizeResponse(
bool Success,
string ErrorMessage,
double Zoom,
InterfaceSize Size,
InterfaceSize DefaultSize,
string ScreenKey,
int ScreenWidth,
int ScreenHeight,
double? ScreenDiagonalInches,
int SystemPercent,
int CurrentPercent,
int AppDefaultPercent,
int MinPercent,
int MaxPercent);
@@ -0,0 +1,9 @@
using AIStudio.Settings.DataModel;
namespace AIStudio.Tools.Rust;
/// <summary>
/// Asks the runtime to show AI Studio in the sizes the user chose.
/// </summary>
/// <param name="Screens">What the user chose for the screens they set a size for, each screen once. Any other screen gets its default size.</param>
public sealed record SetInterfaceSizeRequest(List<InterfaceScreenSize> Screens);
@@ -19,4 +19,6 @@ public enum TauriEventType
GLOBAL_SHORTCUT_PRESSED,
GLOBAL_SHORTCUT_CHANGED,
INTERFACE_SIZE_CHANGED,
}
@@ -0,0 +1,124 @@
using AIStudio.Settings;
using AIStudio.Tools.Rust;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Tools.Services;
/// <summary>
/// Hands the interface sizes the user chose to the runtime, which zooms the webview accordingly.
/// </summary>
/// <remarks>
/// The runtime shows every screen in its default size from the moment the window opens, keeps the
/// sizes, measures the screen again whenever the window moves to another one, and applies the size of
/// that screen. This service only tells it what the user chose: as soon as the settings have loaded,
/// and again whenever they change.
/// </remarks>
public sealed class InterfaceSizeService : BackgroundService, IMessageBusReceiver
{
/// <summary>
/// The smallest custom scale on any screen, in percent. A screen may allow less of the range,
/// which the runtime reports. Keep in sync with MIN_SCALE in runtime/src/interface_zoom.rs.
/// </summary>
private const int MIN_CUSTOM_PERCENT = 60;
/// <summary>
/// The largest custom scale on any screen, in percent. A screen may allow less of the range,
/// which the runtime reports. Keep in sync with MAX_SCALE in runtime/src/interface_zoom.rs.
/// </summary>
private const int MAX_CUSTOM_PERCENT = 300;
/// <summary>
/// How often the service looks whether the settings have loaded. Short, because a screen with a
/// size of its own shows in its default size until then.
/// </summary>
private static readonly TimeSpan SETTINGS_LOAD_CHECK_INTERVAL = TimeSpan.FromMilliseconds(100);
private readonly SemaphoreSlim sendingSemaphore = new(1, 1);
private readonly ILogger<InterfaceSizeService> logger;
private readonly SettingsManager settingsManager;
private readonly MessageBus messageBus;
private readonly RustService rustService;
private string? lastSentSignature;
private volatile bool areSettingsLoaded;
public InterfaceSizeService(ILogger<InterfaceSizeService> logger, SettingsManager settingsManager, MessageBus messageBus, RustService rustService)
{
this.logger = logger;
this.settingsManager = settingsManager;
this.messageBus = messageBus;
this.rustService = rustService;
this.messageBus.RegisterComponent(this);
this.ApplyFilters([], [Event.CONFIGURATION_CHANGED]);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
this.logger.LogInformation("The interface size service was initialized.");
//
// The app loads the settings early, long before its startup completed with the plugins. The
// sizes go to the runtime right then, so that a screen with a size of its own changes from
// its default size as soon as possible:
//
while (!this.settingsManager.HasCompletedInitialSettingsLoad && !stoppingToken.IsCancellationRequested)
await Task.Delay(SETTINGS_LOAD_CHECK_INTERVAL, stoppingToken);
this.areSettingsLoaded = true;
await this.SendInterfaceSize();
await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken);
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
this.messageBus.Unregister(this);
await base.StopAsync(cancellationToken);
}
#region IMessageBusReceiver
public async Task ProcessMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data)
{
// Until the settings have loaded, ExecuteAsync waits to send them:
if (triggeredEvent is Event.CONFIGURATION_CHANGED && this.areSettingsLoaded)
await this.SendInterfaceSize();
}
public Task<MessageBusAnswer<TResult>> ProcessMessageWithResult<TPayload, TResult>(ComponentBase? sendingComponent, Event triggeredEvent, TPayload? data) => Task.FromResult(MessageBusAnswer<TResult>.NONE);
#endregion
/// <summary>
/// Sends the chosen sizes to the runtime, unless the runtime has them already.
/// </summary>
/// <remarks>
/// Every change of any setting announces itself the same way, so most of them leave the sizes
/// as they were. A scale beyond the range of any screen, e.g., in settings edited by hand, is
/// brought within that range here, and the runtime narrows it to the screen.
/// </remarks>
private async Task SendInterfaceSize()
{
var screens = this.settingsManager.ConfigurationData.App.InterfaceScreenSizes
.DistinctBy(screen => screen.ScreenKey)
.Select(screen => screen with { CustomPercent = screen.CustomPercent is { } percent ? Math.Clamp(percent, MIN_CUSTOM_PERCENT, MAX_CUSTOM_PERCENT) : null })
.ToList();
// The order of the screens tells when they were set, which the runtime does not need to know:
var signature = string.Join(";", screens.OrderBy(screen => screen.ScreenKey, StringComparer.Ordinal).Select(screen => $"{screen.ScreenKey}={screen.Size}:{screen.CustomPercent}"));
await this.sendingSemaphore.WaitAsync();
try
{
if (signature == this.lastSentSignature)
return;
if (await this.rustService.SetInterfaceSize(new SetInterfaceSizeRequest(screens)) is not null)
this.lastSentSignature = signature;
}
finally
{
this.sendingSemaphore.Release();
}
}
}
@@ -193,4 +193,63 @@ public sealed partial class RustService
return false;
}
}
/// <summary>
/// Asks the runtime to show AI Studio in the given size, which it reaches by zooming the webview.
/// </summary>
/// <param name="request">The size, and the scale of the custom size.</param>
/// <returns>What the size comes to on the screen AI Studio shows on, or null when the runtime could not apply it.</returns>
public async Task<InterfaceSizeResponse?> SetInterfaceSize(SetInterfaceSizeRequest request)
{
try
{
var response = await this.http.PostAsJsonAsync("/app/interface-size", request, this.jsonRustSerializerOptions);
var result = await this.ReadInterfaceSizeResponse(response, "set the interface size");
if (result is not null)
this.logger?.LogInformation("Set the interface sizes of {ScreenCount} screens. AI Studio shows with the size '{Size}' at {CurrentPercent} % and a zoom of {Zoom:0.###} on the screen '{ScreenKey}'.", request.Screens.Count, result.Size, result.CurrentPercent, result.Zoom, result.ScreenKey);
return result;
}
catch (Exception ex)
{
this.logger?.LogError(ex, "Exception while setting the interface size.");
return null;
}
}
/// <summary>
/// Asks the runtime what the interface size comes to on the screen AI Studio shows on, e.g., which custom scales it allows.
/// </summary>
/// <returns>The report of the runtime, or null when it could not measure the screen.</returns>
public async Task<InterfaceSizeResponse?> GetInterfaceSize()
{
try
{
var response = await this.http.GetAsync("/app/interface-size");
return await this.ReadInterfaceSizeResponse(response, "read the interface size");
}
catch (Exception ex)
{
this.logger?.LogError(ex, "Exception while reading the interface size.");
return null;
}
}
private async Task<InterfaceSizeResponse?> ReadInterfaceSizeResponse(HttpResponseMessage response, string action)
{
if (!response.IsSuccessStatusCode)
{
this.logger?.LogError("Failed to {Action} due to network error: {StatusCode}.", action, response.StatusCode);
return null;
}
var result = await response.Content.ReadFromJsonAsync<InterfaceSizeResponse>(this.jsonRustSerializerOptions);
if (result is null || !result.Success)
{
this.logger?.LogError("Failed to {Action}: {Error}", action, result?.ErrorMessage ?? "Unknown error");
return null;
}
return result;
}
}