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

@@ -4168,6 +4168,33 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONFILE::T1984243691"] = "You c
-- Choose File
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONFILE::T4285779702"] = "Choose File"
-- 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.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T1103704506"] = "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."
-- You can choose from {0} % to {1} %. The app default on this screen is {2} %.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T1110635214"] = "You can choose from {0} % to {1} %. The app default on this screen is {2} %."
-- {0} × {1} pixels, {2:0.#} inches
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T2376030041"] = "{0} × {1} pixels, {2:0.#} inches"
-- You can choose from {0} % to {1} %. The system scaling on this screen is {2} %.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3009448744"] = "You can choose from {0} % to {1} %. The system scaling on this screen is {2} %."
-- Custom interface size on this screen
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3268281815"] = "Custom interface size on this screen"
-- AI Studio cannot measure this screen right now, so you cannot set the interface size here.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3560281885"] = "AI Studio cannot measure this screen right now, so you cannot set the interface size here."
-- Interface size on this screen
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3568633122"] = "Interface size on this screen"
-- Applies to this screen only ({0}). System scaling follows your operating system.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3653703912"] = "Applies to this screen only ({0}). System scaling follows your operating system."
-- {0} × {1} pixels
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T990356864"] = "{0} × {1} pixels"
-- Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONMINCONFIDENCESELECTION::T2526727283"] = "Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level."
@@ -12517,6 +12544,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1258653480
-- No key is sending the input
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1311973034"] = "No key is sending the input"
-- App default
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1382522201"] = "App default"
-- Navigation never expands, no tooltips
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1402851833"] = "Navigation never expands, no tooltips"
@@ -12562,6 +12592,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T238099712"
-- Delete disappearing chats older than 1 year
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T2405705561"] = "Delete disappearing chats older than 1 year"
-- Custom
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T2502277006"] = "Custom"
-- Store chats manually
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T2520325466"] = "Store chats manually"
@@ -12625,6 +12658,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T4256323669
-- All preview features are hidden
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T4289410063"] = "All preview features are hidden"
-- System scaling
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T453866521"] = "System scaling"
-- Chat
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T578410699"] = "Chat"
@@ -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.")"/>
@@ -4170,6 +4170,33 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONFILE::T1984243691"] = "Sie k
-- Choose File
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONFILE::T4285779702"] = "Datei auswählen"
-- 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.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T1103704506"] = "Gilt nur für diesen Bildschirm ({0}). Der App-Standard zeigt AI Studio auf jedem Laptop in derselben Größe an. Die Systemskalierung richtet sich nach Ihrem Betriebssystem."
-- You can choose from {0} % to {1} %. The app default on this screen is {2} %.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T1110635214"] = "Sie können zwischen {0} % und {1} % wählen. Der App-Standard beträgt auf diesem Bildschirm {2} %."
-- {0} × {1} pixels, {2:0.#} inches
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T2376030041"] = "{0} × {1} Pixel, {2:0.#} Zoll"
-- You can choose from {0} % to {1} %. The system scaling on this screen is {2} %.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3009448744"] = "Sie können zwischen {0} % und {1} % wählen. Die Systemskalierung beträgt auf diesem Bildschirm {2} %."
-- Custom interface size on this screen
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3268281815"] = "Benutzerdefinierte Größe der Benutzeroberfläche auf diesem Bildschirm"
-- AI Studio cannot measure this screen right now, so you cannot set the interface size here.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3560281885"] = "AI Studio kann diesen Bildschirm derzeit nicht vermessen, daher können Sie die Größe der Benutzeroberfläche hier nicht festlegen."
-- Interface size on this screen
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3568633122"] = "Größe der Benutzeroberfläche auf diesem Bildschirm"
-- Applies to this screen only ({0}). System scaling follows your operating system.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3653703912"] = "Gilt nur für diesen Bildschirm ({0}). Die Systemskalierung richtet sich nach Ihrem Betriebssystem."
-- {0} × {1} pixels
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T990356864"] = "{0} × {1} Pixel"
-- Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONMINCONFIDENCESELECTION::T2526727283"] = "Wählen Sie das minimale Vertrauensniveau, das alle LLM-Anbieter erfüllen müssen. So stellen Sie sicher, dass nur vertrauenswürdige Anbieter verwendet werden. Anbieter, die dieses Niveau unterschreiten, können nicht verwendet werden."
@@ -12519,6 +12546,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1258653480
-- No key is sending the input
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1311973034"] = "Keine Taste sendet die Eingabe; Sie müssen auf die Schaltfläche klicken, um zu senden"
-- App default
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1382522201"] = "App-Standard"
-- Navigation never expands, no tooltips
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1402851833"] = "Navigationsleiste wird nie erweitert, keine Tooltips; es werden nur Icons angezeigt"
@@ -12564,6 +12594,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T238099712"
-- Delete disappearing chats older than 1 year
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T2405705561"] = "Selbstlöschende Chats: lösche Chats die älter als 1 Jahr sind"
-- Custom
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T2502277006"] = "Benutzerdefiniert"
-- Store chats manually
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T2520325466"] = "Chats manuell speichern"
@@ -12627,6 +12660,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T4256323669
-- All preview features are hidden
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T4289410063"] = "Alle Vorschaufunktionen sind ausgeblendet"
-- System scaling
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T453866521"] = "Systemskalierung"
-- Chat
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T578410699"] = "Chat"
@@ -4170,6 +4170,33 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONFILE::T1984243691"] = "You c
-- Choose File
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONFILE::T4285779702"] = "Choose File"
-- 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.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T1103704506"] = "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."
-- You can choose from {0} % to {1} %. The app default on this screen is {2} %.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T1110635214"] = "You can choose from {0} % to {1} %. The app default on this screen is {2} %."
-- {0} × {1} pixels, {2:0.#} inches
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T2376030041"] = "{0} × {1} pixels, {2:0.#} inches"
-- You can choose from {0} % to {1} %. The system scaling on this screen is {2} %.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3009448744"] = "You can choose from {0} % to {1} %. The system scaling on this screen is {2} %."
-- Custom interface size on this screen
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3268281815"] = "Custom interface size on this screen"
-- AI Studio cannot measure this screen right now, so you cannot set the interface size here.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3560281885"] = "AI Studio cannot measure this screen right now, so you cannot set the interface size here."
-- Interface size on this screen
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3568633122"] = "Interface size on this screen"
-- Applies to this screen only ({0}). System scaling follows your operating system.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T3653703912"] = "Applies to this screen only ({0}). System scaling follows your operating system."
-- {0} × {1} pixels
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONINTERFACESIZE::T990356864"] = "{0} × {1} pixels"
-- Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::CONFIGURATIONMINCONFIDENCESELECTION::T2526727283"] = "Choose the minimum confidence level that all LLM providers must meet. This way, you can ensure that only trustworthy providers are used. You cannot use any provider that falls below this level."
@@ -12519,6 +12546,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1258653480
-- No key is sending the input
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1311973034"] = "No key is sending the input; you have to click the send button"
-- App default
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1382522201"] = "App default"
-- Navigation never expands, no tooltips
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T1402851833"] = "Navigation never expands, no tooltips; there are only icons"
@@ -12564,6 +12594,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T238099712"
-- Delete disappearing chats older than 1 year
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T2405705561"] = "Disappearing chats: delete chats older than 1 year"
-- Custom
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T2502277006"] = "Custom"
-- Store chats manually
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T2520325466"] = "Store chats manually"
@@ -12627,6 +12660,9 @@ UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T4256323669
-- All preview features are hidden
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T4289410063"] = "All preview features are hidden"
-- System scaling
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T453866521"] = "System scaling"
-- Chat
UI_TEXT_CONTENT["AISTUDIO::SETTINGS::CONFIGURATIONSELECTDATAFACTORY::T578410699"] = "Chat"
+1
View File
@@ -242,6 +242,7 @@ internal sealed class Program
builder.Services.AddHostedService(sp => sp.GetRequiredService<DataSourceEmbeddingService>());
builder.Services.AddSingleton<DatabaseClientProvider>();
builder.Services.AddHostedService<GlobalShortcutService>(serviceProvider => serviceProvider.GetRequiredService<GlobalShortcutService>());
builder.Services.AddHostedService<InterfaceSizeService>();
builder.Services.AddHostedService<RustAvailabilityMonitorService>();
builder.Services.AddSingleton<NativeShareService>();
builder.Services.AddScoped<PluginShareService>();
@@ -146,6 +146,19 @@ public static class ConfigurationSelectDataFactory
yield return new(TB("Settings"), StartPage.SETTINGS);
}
/// <summary>
/// The interface sizes a screen offers.
/// </summary>
/// <param name="offerAppDefault">Whether to offer the app default, which only differs from the system scaling on the screen of a laptop.</param>
public static IEnumerable<ConfigurationSelectData<InterfaceSize>> GetInterfaceSizeData(bool offerAppDefault)
{
yield return new(TB("System scaling"), InterfaceSize.SYSTEM_SCALING);
if (offerAppDefault)
yield return new(TB("App default"), InterfaceSize.APP_DEFAULT);
yield return new(TB("Custom"), InterfaceSize.CUSTOM);
}
public static IEnumerable<ConfigurationSelectData<IconSources>> GetIconSourcesData()
{
foreach (var source in Enum.GetValues<IconSources>())
@@ -66,6 +66,49 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
/// </summary>
public StartPage StartPage { get; set; } = ManagedConfiguration.Register(configSelection, n => n.StartPage, StartPage.HOME);
/// <summary>
/// How large AI Studio shows its interface on the screens the user chose a size for, the most
/// recently set first.
/// </summary>
/// <remarks>
/// Kept per screen, as the operating systems keep their scaling per monitor: a laptop calls for
/// another size than an external monitor, and the same scale looks different on screens of
/// different densities. A screen missing here gets InterfaceSize.APP_DEFAULT when it is the
/// screen of a laptop, and InterfaceSize.SYSTEM_SCALING otherwise. The
/// user's own, like the scaling of the operating system: no configuration plugin manages it.
/// </remarks>
public List<InterfaceScreenSize> InterfaceScreenSizes { get; set; } = [];
/// <summary>
/// How many screens keep their interface size. Setting the size of one more forgets the screen
/// whose size was set the longest time ago, which then gets its default size again.
/// </summary>
public const int MAX_REMEMBERED_INTERFACE_SCREENS = 20;
/// <summary>
/// What the user chose for the given screen, or null when they chose nothing for it.
/// </summary>
/// <param name="screenKey">The screen, as the runtime names it.</param>
public InterfaceScreenSize? GetInterfaceScreenSize(string screenKey) => this.InterfaceScreenSizes.FirstOrDefault(screen => screen.ScreenKey == screenKey);
/// <summary>
/// Remembers what the user chose for a screen, as the most recently set one.
/// </summary>
/// <remarks>
/// Keeps MAX_REMEMBERED_INTERFACE_SCREENS screens at most and forgets the ones beyond, those whose
/// size was set the longest time ago. Replaces the list instead of changing it, since the
/// InterfaceSizeService may read it at the same time.
/// </remarks>
/// <param name="screenSize">What the user chose for the screen.</param>
public void RememberInterfaceScreenSize(InterfaceScreenSize screenSize)
{
this.InterfaceScreenSizes = this.InterfaceScreenSizes
.Where(screen => screen.ScreenKey != screenSize.ScreenKey)
.Prepend(screenSize)
.Take(MAX_REMEMBERED_INTERFACE_SCREENS)
.ToList();
}
/// <summary>
/// Whether an alert dialog should be shown when prompt-injection content is blocked.
/// </summary>
@@ -0,0 +1,9 @@
namespace AIStudio.Settings.DataModel;
/// <summary>
/// The interface size the user chose for one screen.
/// </summary>
/// <param name="ScreenKey">The screen, as the runtime names it: its resolution and its physical size, such as "1920x1200@287x179mm".</param>
/// <param name="Size">The size on this screen.</param>
/// <param name="CustomPercent">The scale of the custom size in percent, counted like the scaling of the operating system, or null when the user never set one for this screen. Kept while another size applies, so that it returns with the custom size.</param>
public sealed record InterfaceScreenSize(string ScreenKey, InterfaceSize Size, int? CustomPercent);
@@ -0,0 +1,24 @@
namespace AIStudio.Settings.DataModel;
/// <summary>
/// How large AI Studio shows its interface on a screen.
/// </summary>
public enum InterfaceSize
{
/// <summary>
/// The same size on every laptop, whatever the scaling of its system. The default on the screen
/// of a laptop. Larger screens, such as external monitors, and screens which do not report their
/// size keep the scaling of the system with it.
/// </summary>
APP_DEFAULT,
/// <summary>
/// The scaling of the system, unchanged. The default on any screen but the one of a laptop.
/// </summary>
SYSTEM_SCALING,
/// <summary>
/// A scale of the user's own, see InterfaceScreenSize.CustomPercent.
/// </summary>
CUSTOM,
}
@@ -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;
}
}
+40
View File
@@ -122,6 +122,46 @@
color: var(--custom-icon-color) !important;
}
/*
WebKitGTK, the webview on Linux, leaves the zoom of the page out of the font size of an SVG
element. MudBlazor sizes its icons with 1em of that font size, so they would keep their size while
everything around them grows or shrinks with the interface size. The runtime therefore sets
--svg-icon-zoom to its zoom on Linux, see interface_zoom.rs. The webviews of Windows and macOS zoom
SVG elements like everything else, and the variable stays unset there. The :where keeps this as
weak as MudBlazor's own rule, so that every more specific size still wins.
*/
.mud-icon-root:where(svg) {
width: calc(1em * var(--svg-icon-zoom, 1));
height: calc(1em * var(--svg-icon-zoom, 1));
}
/*
MudBlazor draws the thumb of a slider as a dot of 4px, scaled up five times. Zoomed to an interface
size, the dot covers a fraction of a pixel, which the webview rounds to whole pixels before the
scaling magnifies the error: the thumb turns into an uneven shape. These rules draw the thumb in its
final size instead, with the shadows MudBlazor's scaling gave it. All sliders of the app have the
medium size and the primary color.
*/
.mud-slider.mud-slider-medium .mud-slider-input::-webkit-slider-thumb {
width: 20px;
height: 20px;
margin-top: -8px;
transform: none;
}
.mud-slider.mud-slider-medium.mud-slider-primary .mud-slider-input::-webkit-slider-thumb {
box-shadow: 0 5px 10px -5px rgba(var(--mud-palette-primary-rgb), 0.3);
}
.mud-slider.mud-slider-medium.mud-slider-primary .mud-slider-input::-webkit-slider-thumb:hover {
box-shadow: 0 5px 10px -5px rgba(var(--mud-palette-primary-rgb), 0.3), 0 0 0 5px rgba(var(--mud-palette-primary-rgb), 0.24);
}
.mud-slider.mud-slider-medium.mud-slider-primary .mud-slider-input:focus-visible::-webkit-slider-thumb,
.mud-slider.mud-slider-medium.mud-slider-primary .mud-slider-input:active::-webkit-slider-thumb {
box-shadow: 0 5px 10px -5px rgba(var(--mud-palette-primary-rgb), 0.3), 0 0 0 10px rgba(var(--mud-palette-primary-rgb), 0.24);
}
.improvedDiff .wikEdDiffFragment {
background-color: initial !important;
}
@@ -1,4 +1,6 @@
# v26.11.1, build 260 (2026-11-xx xx:xx UTC)
- Added a setting for the size of the interface, which you find in the app settings. You can choose a size for each of your screens, and AI Studio switches to it when you move its window to another screen. Thanks, Paul Koudelka (`PaulKoudelka`), for this contribution.
- Changed the size of AI Studio on laptop screens: it now appears in the same size on every laptop, whatever the scaling of your operating system. If you prefer the previous size, choose "System scaling" for the interface size in the app settings. Contributed by Paul Koudelka.
- Fixed AI Studio becoming unusable when an update failed after its download. The app now restarts with the version you have installed, so you can simply try the update again.
- Upgraded several libraries to improve security.
- Upgraded Tauri to v2.12.5.
@@ -0,0 +1,60 @@
using AIStudio.Settings.DataModel;
namespace AIStudio.Tests.Settings;
/// <summary>
/// Checks how the settings remember the interface size of each screen.
/// </summary>
/// <remarks>
/// The list keeps the most recently set screen first and forgets the oldest beyond its limit, so that a
/// user who meets many screens, e.g., at changing desks, does not grow the settings without end.
/// </remarks>
[TestFixture]
public sealed class InterfaceScreenSizesTests
{
private const string LAPTOP = "1920x1200@287x179mm";
private const string MONITOR = "3840x2160@597x336mm";
[Test]
public void AScreenTheUserChoseNothingForHasNoSize() => Assert.That(new DataApp().GetInterfaceScreenSize(LAPTOP), Is.Null);
[Test]
public void TheMostRecentlySetScreenComesFirst()
{
var app = new DataApp();
app.RememberInterfaceScreenSize(new(LAPTOP, InterfaceSize.APP_DEFAULT, null));
app.RememberInterfaceScreenSize(new(MONITOR, InterfaceSize.CUSTOM, 150));
Assert.That(app.InterfaceScreenSizes.Select(screen => screen.ScreenKey), Is.EqualTo(new[] { MONITOR, LAPTOP }));
}
[Test]
public void SettingAScreenAgainReplacesItsSizeAndMovesItToTheFront()
{
var app = new DataApp();
app.RememberInterfaceScreenSize(new(LAPTOP, InterfaceSize.CUSTOM, 120));
app.RememberInterfaceScreenSize(new(MONITOR, InterfaceSize.SYSTEM_SCALING, null));
app.RememberInterfaceScreenSize(new(LAPTOP, InterfaceSize.SYSTEM_SCALING, 120));
Assert.Multiple(() =>
{
Assert.That(app.InterfaceScreenSizes.Select(screen => screen.ScreenKey), Is.EqualTo(new[] { LAPTOP, MONITOR }));
Assert.That(app.GetInterfaceScreenSize(LAPTOP), Is.EqualTo(new InterfaceScreenSize(LAPTOP, InterfaceSize.SYSTEM_SCALING, 120)));
});
}
[Test]
public void BeyondTheLimitTheScreenSetTheLongestTimeAgoIsForgotten()
{
var app = new DataApp();
for (var screen = 0; screen <= DataApp.MAX_REMEMBERED_INTERFACE_SCREENS; screen++)
app.RememberInterfaceScreenSize(new($"{screen}x1080", InterfaceSize.CUSTOM, 100));
Assert.Multiple(() =>
{
Assert.That(app.InterfaceScreenSizes, Has.Count.EqualTo(DataApp.MAX_REMEMBERED_INTERFACE_SCREENS));
Assert.That(app.GetInterfaceScreenSize("0x1080"), Is.Null);
Assert.That(app.GetInterfaceScreenSize("1x1080"), Is.Not.Null);
});
}
}
+1
View File
@@ -4297,6 +4297,7 @@ dependencies = [
"file-format",
"flexi_logger",
"futures",
"gtk",
"hmac 0.13.0",
"image",
"keyring-core",
+5 -4
View File
@@ -101,10 +101,10 @@ pptx-to-md = { git = "https://github.com/SommerEngineering/pptx-parser.git", rev
windows-registry = "0.6.1"
windows-native-keyring-store = "1.1.0"
# The share sheet hands the window handle it gets from Tauri to the Windows API of this crate. Both
# sides must use the same version of `windows`, otherwise the handle types differ and the build fails
# on Windows. Keep this pin, and `windows-collections` with it, at the version Tauri uses.
windows = { version = "=0.62.2", features = ["ApplicationModel_DataTransfer", "Foundation", "Foundation_Collections", "Storage", "Storage_Streams", "Win32_Foundation", "Win32_System_WinRT", "Win32_UI_Shell"] }
# The share sheet and the interface size hand the window handle they get from Tauri to the Windows API
# of this crate. Both sides must use the same version of `windows`, otherwise the handle types differ and
# the build fails on Windows. Keep this pin, and `windows-collections` with it, at the version Tauri uses.
windows = { version = "=0.62.2", features = ["ApplicationModel_DataTransfer", "Foundation", "Foundation_Collections", "Storage", "Storage_Streams", "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_System_WinRT", "Win32_UI_HiDpi", "Win32_UI_Shell"] }
windows-collections = "=0.3.2"
[target.'cfg(target_os = "macos")'.dependencies]
@@ -117,6 +117,7 @@ objc2-foundation = { version = "0.3.2", features = ["NSArray", "NSBundle", "NSGe
ashpd = { version = "0.13.12", default-features = false, features = ["tokio", "open_uri", "global_shortcuts"] }
dbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
dbus-secret-service = "4.1.0"
gtk = "0.18"
webkit2gtk = { version = "2.0.2", features = ["v2_8"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
+77 -21
View File
@@ -14,6 +14,7 @@ use pdfium_render::prelude::Pdfium;
use serde::{Deserialize, Serialize};
use tauri::{DragDropEvent,RunEvent, Manager, WindowEvent};
use tauri::path::PathResolver;
use tauri::webview::PageLoadEvent;
use tauri::WebviewWindow;
use tauri::PhysicalPosition;
use tauri_plugin_updater::{UpdaterExt, Update};
@@ -141,6 +142,21 @@ pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
)
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build())
.on_page_load(|webview, payload| {
//
// A webview may forget its zoom when it loads a page from another origin, as the page
// of the app is, see interface_zoom.rs. Measuring the screen needs the main thread,
// which this handler is not guaranteed to run on:
//
if matches!(payload.event(), PageLoadEvent::Finished)
&& let Some(window) = webview.app_handle().get_webview_window(webview.label())
{
let ui_window = window.clone();
if let Err(error) = window.run_on_main_thread(move || crate::interface_zoom::zoom_window(&ui_window)) {
warn!(Source = "Interface size"; "Failed to schedule the zoom after a page load: {error}");
}
}
})
.setup(move |app| {
// Get the main window:
@@ -162,17 +178,25 @@ pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
window.on_window_event(move |event| {
//
// Only a drag and drop event carries a position, and only that position needs the
// scale factor. Asking the window on every window event would be needless work.
// Asking it anew for every drag is what keeps a display change covered: we hold no
// factor of our own which a moved window could leave behind.
// A window which moved may rest on another screen now, whose size calls for
// another zoom of the webview:
//
let scale_factor = match event {
WindowEvent::DragDrop(_) => event_window.scale_factor().unwrap_or(1.0),
_ => 1.0,
if matches!(event, WindowEvent::Moved(_) | WindowEvent::ScaleFactorChanged { .. }) {
crate::interface_zoom::follow_window(&event_window);
}
//
// Only a drag and drop event carries a position, and only that position needs the
// scale factor and the zoom. Asking the window on every window event would be
// needless work. Asking it anew for every drag is what keeps a display change
// covered: we hold no factor of our own which a moved window could leave behind.
//
let (scale_factor, zoom) = match event {
WindowEvent::DragDrop(_) => (event_window.scale_factor().unwrap_or(1.0), crate::interface_zoom::current_zoom()),
_ => (1.0, 1.0),
};
let Some(event_to_send) = Event::from_window_event(event, scale_factor) else {
let Some(event_to_send) = Event::from_window_event(event, scale_factor, zoom) else {
return;
};
@@ -186,6 +210,9 @@ pub fn start_tauri(tauri_context: tauri::Context<tauri::Wry>) {
});
});
// The screen shows in its default size from the start, long before the app sends the sizes the user chose:
crate::interface_zoom::zoom_window(&window);
// Save the main window for later access:
*MAIN_WINDOW.lock().unwrap() = Some(window);
@@ -472,15 +499,17 @@ pub struct CursorPosition {
/// in the first place: a point stays a point when the window moves to a display with a different
/// pixel density, and only the number of device pixels behind it changes.
///
/// What the equality of a logical point and a CSS pixel does depend on is that nobody zooms the
/// webview: neither through WebviewWindow::set_zoom nor through zoomHotkeysEnabled, which our
/// tauri.conf.json leaves off. Should AI Studio ever offer a zoom, say for accessibility, the
/// position has to be divided by it as well -- on every platform, this time.
/// A logical point equals a CSS pixel only as long as the webview is not zoomed. AI Studio zooms it
/// to show in the same size on every laptop, see interface_zoom.rs, and a zoomed CSS pixel covers
/// that many logical points. The position is therefore divided by the zoom as well, on every
/// platform. Keyboard zooming stays off: tauri.conf.json leaves zoomHotkeysEnabled unset, and a
/// zoom we did not set ourselves would put the position off.
///
/// The decision is written with cfg! rather than #[cfg], so that both branches are compiled and
/// type-checked on every platform instead of only on the one they apply to.
fn cursor_position_in_css_pixels(position: PhysicalPosition<f64>, scale_factor: f64) -> CursorPosition {
scale_cursor_position(position, if cfg!(target_os = "windows") { scale_factor } else { 1.0 })
fn cursor_position_in_css_pixels(position: PhysicalPosition<f64>, scale_factor: f64, zoom: f64) -> CursorPosition {
let platform_scale_factor = if cfg!(target_os = "windows") { scale_factor } else { 1.0 };
scale_cursor_position(position, platform_scale_factor * zoom)
}
/// Divides a cursor position by a scale factor.
@@ -538,7 +567,10 @@ impl Event {
}
/// Creates an Event instance from a Tauri WindowEvent, unless the event is none of our business.
pub fn from_window_event(window_event: &WindowEvent, scale_factor: f64) -> Option<Self> {
///
/// The scale factor and the zoom only matter for the position of a drag and drop event, see
/// `cursor_position_in_css_pixels`.
pub fn from_window_event(window_event: &WindowEvent, scale_factor: f64, zoom: f64) -> Option<Self> {
match window_event {
WindowEvent::DragDrop(drop_event) => {
match drop_event {
@@ -547,7 +579,7 @@ impl Event {
Some(Event::with_position(
TauriEventType::FileDropHovered,
paths.iter().map(|p| p.display().to_string()).collect(),
cursor_position_in_css_pixels(*position, scale_factor),
cursor_position_in_css_pixels(*position, scale_factor, zoom),
))
},
@@ -564,7 +596,7 @@ impl Event {
Some(Event::with_position(
TauriEventType::FileDropOver,
Vec::new(),
cursor_position_in_css_pixels(*position, scale_factor),
cursor_position_in_css_pixels(*position, scale_factor, zoom),
))
},
@@ -573,7 +605,7 @@ impl Event {
Some(Event::with_position(
TauriEventType::FileDropDropped,
paths.iter().map(|p| p.display().to_string()).collect(),
cursor_position_in_css_pixels(*position, scale_factor),
cursor_position_in_css_pixels(*position, scale_factor, zoom),
))
},
@@ -625,6 +657,24 @@ pub enum TauriEventType {
GlobalShortcutPressed,
GlobalShortcutChanged,
/// The interface size on the screen the window shows on changed, see interface_zoom.rs.
InterfaceSizeChanged,
}
/// Sends an event to the app.
///
/// Locks only the broadcast sender, never the main window, so it may run on the main thread.
pub fn send_event(event: Event) {
let event_sender = EVENT_BROADCAST.lock().unwrap().clone();
let Some(event_sender) = event_sender else {
warn!(Source = "Tauri"; "Cannot send the event {:?} before the event channel exists.", event.event_type);
return;
};
if let Err(error) = event_sender.send(event) {
error!(Source = "Tauri"; "Failed to send an event: {error}");
}
}
/// Changes the location of the main window to the given URL.
@@ -1181,20 +1231,26 @@ mod tests {
#[test]
fn the_cursor_position_is_scaled_on_windows_only() {
let position = cursor_position_in_css_pixels(PhysicalPosition::new(200.0, 100.0), 2.0);
let position = cursor_position_in_css_pixels(PhysicalPosition::new(200.0, 100.0), 2.0, 1.0);
let expected = if cfg!(target_os = "windows") { (100.0, 50.0) } else { (200.0, 100.0) };
assert_eq!((position.x, position.y), expected);
}
#[test]
fn the_zoom_divides_the_cursor_position_on_every_platform() {
let position = cursor_position_in_css_pixels(PhysicalPosition::new(200.0, 100.0), 1.0, 2.0);
assert_eq!((position.x, position.y), (100.0, 50.0));
}
#[test]
fn a_window_event_we_do_not_care_about_is_not_channeled() {
assert!(Event::from_window_event(&WindowEvent::Destroyed, 1.0).is_none());
assert!(Event::from_window_event(&WindowEvent::Destroyed, 1.0, 1.0).is_none());
}
#[test]
fn losing_the_window_focus_is_channeled_without_a_position() {
let event = Event::from_window_event(&WindowEvent::Focused(false), 1.0).unwrap();
let event = Event::from_window_event(&WindowEvent::Focused(false), 1.0, 1.0).unwrap();
assert!(matches!(event.event_type, TauriEventType::WindowNotFocused));
assert!(event.position.is_none());
+917
View File
@@ -0,0 +1,917 @@
//! Zooms the webview to the interface size the user chose.
//!
//! The app draws the same number of CSS pixels on every system. How large a CSS pixel appears
//! depends on the density of the screen and on the scaling of the system: a 13-inch laptop with
//! 1920 pixels across shows normal text half again as large at a scaling of 150 percent as at 100
//! percent. Web content cannot learn the physical size of a screen, since CSS fixes its inch at 96
//! pixels. The runtime can ask the system, though, and zoom the webview accordingly.
//!
//! The user chooses one of three sizes for every screen, as the systems keep their scaling per
//! monitor:
//!
//! - **System scaling** leaves the webview as the system scales it.
//! - **App default** zooms the screen of a laptop to a reference density, so that the
//! same text has the same size on every laptop. A large external monitor stands farther away, and
//! text of the same physical size would look smaller on it. Such a monitor, like any screen which
//! does not report its size, keeps the scaling of the system.
//! - **Custom** applies a scale of the user's own, counted like the scaling of the system: device
//! pixels per CSS pixel, so that 150 percent on Windows means what it means in its settings.
//!
//! A screen the user set nothing for gets App default when it is the screen of a laptop, and the
//! scaling of the system otherwise, which is what App default would show there anyway.
//!
//! Every screen limits the scales it allows: the layout needs a minimum width in CSS pixels, and text
//! must stay large enough to read. A custom scale beyond the limits of a screen is brought within
//! them there, while the setting itself stays as the user chose it.
//!
//! The webview shows in the default size of its screen from the moment the window opens, long before
//! the app has loaded the settings and can send the sizes the user chose. Most users never choose
//! one, and their screens thus never change size while AI Studio starts.
//!
//! Whenever the size on the screen changes, because the user changed it or the window moved to
//! another screen, the runtime tells the app, so that its settings show the screen the window is on.
use std::collections::HashMap;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use axum::Json;
use log::{info, warn};
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use tauri::{Monitor, WebviewWindow};
use crate::app_window::{Event, TauriEventType};
/// The density App default aims for on the screen of a laptop, in CSS pixels per inch.
///
/// Normal text, 16 CSS pixels tall, is about three millimeters tall at this density, which proved
/// comfortable to read while it leaves plenty of room for content. A 14-inch laptop screen with
/// 1920 pixels across shows at about 120 percent with it, a 13.3-inch one at about 126 percent,
/// where Windows uses 150, and the 14-inch screen of a MacBook Pro at about 188 percent, where
/// macOS uses 200.
const REFERENCE_CSS_PIXELS_PER_INCH: f64 = 135.0;
/// The highest density a scale may leave, in CSS pixels per inch. Normal text, 16 CSS pixels tall,
/// stays at least about two millimeters tall this way.
const MAX_CSS_PIXELS_PER_INCH: f64 = 200.0;
/// The narrowest a screen may become in CSS pixels, which is the narrowest layout AI Studio supports.
const MIN_CSS_WIDTH: f64 = 1024.0;
/// The smallest scale on any screen, as device pixels per CSS pixel. Keep in sync with
/// MIN_CUSTOM_PERCENT in InterfaceSizeService of the app.
const MIN_SCALE: f64 = 0.6;
/// The largest scale on any screen, as device pixels per CSS pixel. Keep in sync with
/// MAX_CUSTOM_PERCENT in InterfaceSizeService of the app.
const MAX_SCALE: f64 = 3.0;
/// How far a scale may shrink below the scaling of the system on a screen of unknown size, where
/// the density which keeps text readable is unknown.
const MIN_SHARE_OF_SYSTEM_SCALE_ON_UNKNOWN_SCREENS: f64 = 0.5;
/// The largest screen which still counts as the screen of a laptop, as its diagonal in inches.
const LARGEST_LAPTOP_SCREEN_INCHES: f64 = 17.0;
/// The steps of the scale in the settings, in percent. The limits a screen reports lie on these steps.
const SCALE_STEP_PERCENT: f64 = 5.0;
/// How long a moved window has to rest before we measure its screen again.
///
/// Moving a window emits an event for every step of the way. Only once the window rests is the
/// screen it ends up on settled, and measuring then is enough.
const SCREEN_CHANGE_DELAY: Duration = Duration::from_millis(300);
const MILLIMETERS_PER_INCH: f64 = 25.4;
/// The interface size of a screen, as the app sends it and as the runtime reports it.
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Serialize)]
pub enum InterfaceSize {
/// The same size on every laptop, after the reference density.
AppDefault,
/// The scaling of the system, unchanged.
SystemScaling,
/// A scale of the user's own, counted like the scaling of the system.
Custom,
}
/// What the user chose for one screen.
#[derive(Clone, Copy, Debug, PartialEq)]
struct ScreenPolicy {
size: InterfaceSize,
/// The custom scale as device pixels per CSS pixel, or none when the user never set one. The
/// screen keeps it while another size applies, so that it returns with the custom size.
custom_scale: Option<f64>,
}
/// What decides the zoom, as the app sent it last.
#[derive(Clone, Debug, Default, PartialEq)]
struct ZoomPolicy {
/// What the user chose for the screens they set a size for, by the key of their screen, see
/// [`Screen::key`]. Any other screen gets its default size, see [`default_size`].
screens: HashMap<String, ScreenPolicy>,
}
/// The policy the app sent last. Until it sends one, every screen gets its default size.
///
/// The window events read this on the main thread. Whoever locks it must therefore never call into
/// Tauri while holding the lock: on macOS, such a call may wait for the main thread, which in turn
/// would wait for the lock.
static POLICY: Lazy<Mutex<ZoomPolicy>> = Lazy::new(|| Mutex::new(ZoomPolicy::default()));
/// What the runtime told the app last, so that it tells it only about a change. The same rule
/// applies as for [`POLICY`]: never call into Tauri while holding the lock.
static LAST_REPORT: Lazy<Mutex<Option<InterfaceSizeReport>>> = Lazy::new(|| Mutex::new(None));
/// The zoom the webview has right now, as the bits of an f64.
///
/// An atomic rather than a lock, because the window events read it on the main thread for every
/// drag and drop, see `cursor_position_in_css_pixels` in `app_window.rs`.
static CURRENT_ZOOM: AtomicU64 = AtomicU64::new(1.0f64.to_bits());
/// Whether a measurement after a move of the window is already scheduled.
static SCREEN_CHANGE_PENDING: AtomicBool = AtomicBool::new(false);
/// The zoom the webview has right now.
pub fn current_zoom() -> f64 {
f64::from_bits(CURRENT_ZOOM.load(Ordering::Acquire))
}
/// What the app sends to set the interface size.
#[derive(Deserialize)]
pub struct SetInterfaceSizeRequest {
/// What the user chose for the screens they set a size for, each screen once.
screens: Vec<ScreenSizeRequest>,
}
/// What the user chose for one screen, as the app sends it.
#[derive(Deserialize)]
struct ScreenSizeRequest {
/// The screen, as the runtime reported it in `screen_key`.
screen_key: String,
size: InterfaceSize,
/// The custom scale in percent, counted like the scaling of the system, or null when the user
/// never set one for this screen.
custom_percent: Option<u32>,
}
/// What the runtime answers about the interface size on the screen the window shows on.
///
/// All percentages count device pixels per CSS pixel, like the scaling of the system.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct InterfaceSizeReport {
/// The zoom the webview has.
zoom: f64,
/// The size on this screen: the one the user chose for it, or else its default size.
size: InterfaceSize,
/// The size of this screen while the user chose none for it, see [`default_size`].
default_size: InterfaceSize,
/// What the app stores the size of this screen by, see [`Screen::key`].
screen_key: String,
/// The width of the screen in device pixels.
screen_width: u32,
/// The height of the screen in device pixels.
screen_height: u32,
/// The diagonal of the screen in inches, or null when the screen does not report its size.
screen_diagonal_inches: Option<f64>,
/// The scaling of the system on this screen.
system_percent: u32,
/// The scale AI Studio shows with.
current_percent: u32,
/// The scale App default gives this screen, which is also where its custom scale starts.
app_default_percent: u32,
/// The smallest custom scale this screen allows.
min_percent: u32,
/// The largest custom scale this screen allows.
max_percent: u32,
}
/// The answer to a request about the interface size.
#[derive(Serialize)]
pub struct InterfaceSizeResponse {
success: bool,
error_message: String,
#[serde(flatten)]
report: InterfaceSizeReport,
}
/// Sets the interface size, and zooms the webview accordingly.
pub async fn set_interface_size(Json(request): Json<SetInterfaceSizeRequest>) -> Json<InterfaceSizeResponse> {
let screens = request.screens.into_iter()
.map(|screen| (screen.screen_key, ScreenPolicy {
size: screen.size,
custom_scale: screen.custom_percent.map(|percent| f64::from(percent) / 100.0),
}))
.collect();
*POLICY.lock().unwrap() = ZoomPolicy { screens };
report_on_main_thread().await
}
/// Reports the interface size on the screen the window shows on, as the settings show it.
pub async fn get_interface_size() -> Json<InterfaceSizeResponse> {
report_on_main_thread().await
}
/// Measures the screen on the main thread, as GTK requires on Linux, zooms the webview when its
/// zoom no longer fits, and reports. Waits for the main thread without blocking the worker.
async fn report_on_main_thread() -> Json<InterfaceSizeResponse> {
let window = crate::app_window::MAIN_WINDOW.lock().unwrap().clone();
let Some(window) = window else {
return failure("The main window is not available.");
};
let (sender, receiver) = tokio::sync::oneshot::channel();
let ui_window = window.clone();
if let Err(error) = window.run_on_main_thread(move || {
let _ = sender.send(apply_zoom(&ui_window, false));
}) {
return failure(&format!("Failed to schedule the measurement of the screen: {error}"));
}
match receiver.await {
Ok(report) => Json(InterfaceSizeResponse {
success: true,
error_message: String::new(),
report,
}),
Err(_) => failure("The measurement of the screen did not return a result."),
}
}
fn failure(message: &str) -> Json<InterfaceSizeResponse> {
warn!(Source = "Interface size"; "{message}");
Json(InterfaceSizeResponse {
success: false,
error_message: message.to_string(),
report: report_for(&ZoomPolicy::default(), None),
})
}
/// Zooms the webview to the size of the screen it shows on, right away.
///
/// Runs on the main thread. The window calls this once it opens, so that its screen shows in its
/// default size from the start, and again whenever a page has loaded: a webview may forget its zoom
/// when it loads a page from another origin, as the page of the app is. The zoom is therefore set
/// anew even when we believe the webview has it already.
pub fn zoom_window(window: &WebviewWindow) {
apply_zoom(window, true);
}
/// Measures the screen again once a moved window rests, since it may rest on another screen now.
///
/// The window events call this on the main thread, for every move and every change of the scale
/// factor. It schedules a single measurement after the move instead of measuring each step.
pub fn follow_window(window: &WebviewWindow) {
if SCREEN_CHANGE_PENDING.swap(true, Ordering::AcqRel) {
return;
}
let window = window.clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(SCREEN_CHANGE_DELAY).await;
SCREEN_CHANGE_PENDING.store(false, Ordering::Release);
let ui_window = window.clone();
if let Err(error) = window.run_on_main_thread(move || {
apply_zoom(&ui_window, false);
}) {
warn!(Source = "Interface size"; "Failed to schedule the zoom after the window moved: {error}");
}
});
}
/// Measures the screen the window shows on, zooms the webview after the policy, and reports.
///
/// Runs on the main thread. Sets the zoom only when it differs from the one the webview has, unless
/// forced to. Tells the app whenever the report differs from the one it heard of last, e.g., after
/// the window moved to another screen.
fn apply_zoom(window: &WebviewWindow, force: bool) -> InterfaceSizeReport {
let policy = POLICY.lock().unwrap().clone();
let screen = measure_screen(window);
let mut report = report_for(&policy, screen);
if force || (report.zoom - current_zoom()).abs() >= 0.001 {
match window.set_zoom(report.zoom) {
Ok(()) => {
CURRENT_ZOOM.store(report.zoom.to_bits(), Ordering::Release);
zoom_svg_icons(window, report.zoom);
info!(Source = "Interface size"; "Zoomed the webview to {:.3} for the screen {screen:?}, which shows AI Studio at {} percent with the size {:?}.", report.zoom, report.current_percent, report.size);
},
Err(error) => {
warn!(Source = "Interface size"; "Failed to zoom the webview to {:.3}: {error}", report.zoom);
report.zoom = current_zoom();
},
}
}
let is_news = {
let mut last_report = LAST_REPORT.lock().unwrap();
let is_news = last_report.as_ref() != Some(&report);
if is_news {
*last_report = Some(report.clone());
}
is_news
};
if is_news {
crate::app_window::send_event(Event::new(TauriEventType::InterfaceSizeChanged, Vec::new()));
}
report
}
/// Hands the zoom to the SVG icons of the page, on Linux, whose webview does not zoom them.
///
/// WebKitGTK, the webview on Linux, leaves the page zoom out of the font size of an SVG element.
/// The icons of MudBlazor take their size from that font size, so they would keep their size while
/// everything around them grows or shrinks. app.css multiplies their size with the CSS variable set
/// here. The webviews of Windows and macOS zoom SVG elements like everything else.
///
/// The variable belongs to the page and is gone once another one loads. Every page load sets the
/// zoom anew, see [`zoom_window`], and thereby the variable as well.
fn zoom_svg_icons(window: &WebviewWindow, zoom: f64) {
if !cfg!(target_os = "linux") {
return;
}
let script = format!("document.documentElement.style.setProperty('--svg-icon-zoom', '{zoom}');");
if let Err(error) = window.eval(&script) {
warn!(Source = "Interface size"; "Failed to hand the zoom to the SVG icons: {error}");
}
}
/// The screen the window shows on.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Screen {
/// The width in device pixels.
device_width: f64,
/// The height in device pixels.
device_height: f64,
/// The scaling of the system on this screen, as device pixels per CSS pixel: 1.5 for 150 percent.
system_scale: f64,
/// The physical width and height in millimeters, as far as the screen reports them.
size_mm: Option<(f64, f64)>,
}
impl Screen {
/// Whether the screen can be measured at all.
fn is_plausible(&self) -> bool {
// A value which is not a number fails these comparisons, so it counts as implausible, too:
self.device_width > 0.0 && self.device_height > 0.0 && self.system_scale > 0.0
&& self.device_width.is_finite() && self.device_height.is_finite() && self.system_scale.is_finite()
}
/// The physical width and height in millimeters, when the screen reports a plausible size.
fn plausible_size_mm(&self) -> Option<(f64, f64)> {
self.size_mm.filter(|(width_mm, height_mm)| *width_mm > 0.0 && *height_mm > 0.0 && width_mm.is_finite() && height_mm.is_finite())
}
/// The density in device pixels per inch, when the screen reports a plausible size.
fn device_pixels_per_inch(&self) -> Option<f64> {
let (width_mm, _) = self.plausible_size_mm()?;
Some(self.device_width / (width_mm / MILLIMETERS_PER_INCH))
}
/// The diagonal in inches, when the screen reports a plausible size.
fn diagonal_inches(&self) -> Option<f64> {
self.plausible_size_mm().map(|(width_mm, height_mm)| width_mm.hypot(height_mm) / MILLIMETERS_PER_INCH)
}
/// Whether the screen is the size of a laptop's own. A screen of unknown size never is.
fn is_laptop_sized(&self) -> bool {
self.diagonal_inches().is_some_and(|diagonal| diagonal <= LARGEST_LAPTOP_SCREEN_INCHES)
}
/// What the app stores the custom scale of this screen by.
///
/// Built from the resolution and the physical size, so that it names the model of a screen
/// rather than the port it hangs on, which the names the systems give their monitors depend on.
/// Two screens of the same model share their scale, which suits a user who meets the same model
/// at another desk. The scaling of the system stays out, since the user may change it.
fn key(&self) -> String {
let resolution = format!("{}x{}", self.device_width.round() as i64, self.device_height.round() as i64);
match self.plausible_size_mm() {
Some((width_mm, height_mm)) => format!("{resolution}@{}x{}mm", width_mm.round() as i64, height_mm.round() as i64),
None => resolution,
}
}
}
/// What the interface size comes to for the given policy on the given screen.
///
/// Without a screen, nothing can be measured, and the webview keeps its zoom of 1.0.
fn report_for(policy: &ZoomPolicy, screen: Option<Screen>) -> InterfaceSizeReport {
let Some(screen) = screen.filter(Screen::is_plausible) else {
return InterfaceSizeReport {
zoom: 1.0,
size: InterfaceSize::SystemScaling,
default_size: InterfaceSize::SystemScaling,
screen_key: String::new(),
screen_width: 0,
screen_height: 0,
screen_diagonal_inches: None,
system_percent: 100,
current_percent: 100,
app_default_percent: 100,
min_percent: to_percent(MIN_SCALE),
max_percent: to_percent(MAX_SCALE),
};
};
let screen_key = screen.key();
let (min_percent, max_percent) = custom_limits_percent(&screen);
let app_default_scale = app_default_scale(&screen);
let default_size = default_size(&screen);
let screen_policy = policy.screens.get(&screen_key);
let (size, scale) = match screen_policy.map_or(default_size, |screen_policy| screen_policy.size) {
InterfaceSize::SystemScaling => (InterfaceSize::SystemScaling, screen.system_scale),
InterfaceSize::AppDefault => (InterfaceSize::AppDefault, app_default_scale),
InterfaceSize::Custom => match screen_policy.and_then(|screen_policy| screen_policy.custom_scale) {
Some(custom_scale) if custom_scale.is_finite() => {
let (min_scale, max_scale) = (f64::from(min_percent) / 100.0, f64::from(max_percent) / 100.0);
(InterfaceSize::Custom, custom_scale.clamp(min_scale, max_scale))
},
// The app always sends a scale with the custom size. Should one be missing anyway, the screen shows as with App default:
_ => (InterfaceSize::Custom, app_default_scale),
},
};
InterfaceSizeReport {
zoom: scale / screen.system_scale,
size,
default_size,
screen_width: screen.device_width.round() as u32,
screen_height: screen.device_height.round() as u32,
screen_diagonal_inches: screen.diagonal_inches(),
screen_key,
system_percent: to_percent(screen.system_scale),
current_percent: to_percent(scale),
app_default_percent: to_percent(app_default_scale),
min_percent,
max_percent,
}
}
/// The size of a screen the user set nothing for: App default on the screen of a laptop, and the
/// scaling of the system on any other, where App default would show the same.
fn default_size(screen: &Screen) -> InterfaceSize {
if screen.is_laptop_sized() {
InterfaceSize::AppDefault
} else {
InterfaceSize::SystemScaling
}
}
/// The scale App default gives a screen: the reference density on the screen of a laptop, and the
/// scaling of the system on any other.
fn app_default_scale(screen: &Screen) -> f64 {
match screen.device_pixels_per_inch() {
Some(density) if screen.is_laptop_sized() => {
let (min_scale, max_scale) = custom_limits(screen);
(density / REFERENCE_CSS_PIXELS_PER_INCH).clamp(min_scale, max_scale)
},
_ => screen.system_scale,
}
}
/// The smallest and the largest scale a screen allows, as device pixels per CSS pixel.
///
/// The largest keeps the screen at least as wide as the narrowest layout AI Studio supports. The
/// smallest keeps text readable: on a screen of known size by its density, on any other by the
/// scaling of the system. Should both collide on a very small screen, the layout wins.
fn custom_limits(screen: &Screen) -> (f64, f64) {
let max_scale = (screen.device_width / MIN_CSS_WIDTH).min(MAX_SCALE);
let readable_scale = match screen.device_pixels_per_inch() {
Some(density) => density / MAX_CSS_PIXELS_PER_INCH,
None => screen.system_scale * MIN_SHARE_OF_SYSTEM_SCALE_ON_UNKNOWN_SCREENS,
};
let min_scale = readable_scale.max(MIN_SCALE).min(max_scale);
(min_scale, max_scale)
}
/// The limits of a screen in percent, on the steps the settings offer and within the limits.
fn custom_limits_percent(screen: &Screen) -> (u32, u32) {
let (min_scale, max_scale) = custom_limits(screen);
// A rounding error of the floating point numbers, such as 0.6 * 100 = 60.000000000000007, must not cost a whole step:
let min_percent = (min_scale * 100.0 / SCALE_STEP_PERCENT - 1e-9).ceil() * SCALE_STEP_PERCENT;
let max_percent = (max_scale * 100.0 / SCALE_STEP_PERCENT + 1e-9).floor() * SCALE_STEP_PERCENT;
(min_percent as u32, max_percent.max(min_percent) as u32)
}
fn to_percent(scale: f64) -> u32 {
(scale * 100.0).round().max(0.0) as u32
}
/// Measures the screen the window shows on. Runs on the main thread.
///
/// The physical size has to come from the same screen as the pixels and the scaling, or the density
/// mixes two screens. Each platform therefore asks for the screen the way Tao does in `current_monitor`.
fn measure_screen(window: &WebviewWindow) -> Option<Screen> {
let monitor = window.current_monitor().ok().flatten()?;
Some(Screen {
device_width: f64::from(monitor.size().width),
device_height: f64::from(monitor.size().height),
system_scale: monitor.scale_factor(),
size_mm: screen_size_mm(window, &monitor),
})
}
/// The physical size of the screen the window shows on, in millimeters, as the monitor reports it.
///
/// Windows looks the screen up the same way Tao does for the monitor: by the window, nearest first.
#[cfg(target_os = "windows")]
fn screen_size_mm(window: &WebviewWindow, _monitor: &Monitor) -> Option<(f64, f64)> {
use windows::Win32::Graphics::Gdi::{GetMonitorInfoW, MonitorFromWindow, MONITORINFO, MONITOR_DEFAULTTONEAREST};
use windows::Win32::UI::HiDpi::{GetDpiForMonitor, MDT_RAW_DPI};
let hwnd = window.hwnd().ok()?;
let monitor = unsafe { MonitorFromWindow(hwnd, MONITOR_DEFAULTTONEAREST) };
if monitor.is_invalid() {
return None;
}
let mut info = MONITORINFO {
cbSize: size_of::<MONITORINFO>() as u32,
..Default::default()
};
if !unsafe { GetMonitorInfoW(monitor, &mut info) }.as_bool() {
return None;
}
// The raw DPI is the density the monitor reports of itself, unlike the one its scaling implies:
let (mut dpi_x, mut dpi_y) = (0u32, 0u32);
unsafe { GetDpiForMonitor(monitor, MDT_RAW_DPI, &mut dpi_x, &mut dpi_y) }.ok()?;
if dpi_x == 0 || dpi_y == 0 {
return None;
}
// Tauri makes the process aware of the DPI of each monitor, so the rectangle is in device pixels:
let width_px = f64::from(info.rcMonitor.right - info.rcMonitor.left);
let height_px = f64::from(info.rcMonitor.bottom - info.rcMonitor.top);
Some((
width_px / f64::from(dpi_x) * MILLIMETERS_PER_INCH,
height_px / f64::from(dpi_y) * MILLIMETERS_PER_INCH,
))
}
/// The physical size of the given screen, in millimeters, as the display reports it.
#[cfg(target_os = "macos")]
fn screen_size_mm(_window: &WebviewWindow, monitor: &Monitor) -> Option<(f64, f64)> {
// Core Graphics reads this point, which the compiler does not see:
#[allow(dead_code)]
#[repr(C)]
struct CGPoint {
x: f64,
y: f64,
}
#[repr(C)]
struct CGSize {
width: f64,
height: f64,
}
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGGetDisplaysWithPoint(point: CGPoint, max_displays: u32, displays: *mut u32, matching_display_count: *mut u32) -> i32;
fn CGDisplayScreenSize(display: u32) -> CGSize;
}
//
// Tao takes the screen macOS shows the window on, the one holding its largest part. Core
// Graphics has to find that display, too, rather than the one under the center of the window: a
// window spanning two screens may have its center on the other one, whose size would then meet
// the pixels of this one. Hence the center of the screen itself.
//
// Tao reports the position and the size of a screen in physical pixels, from the top left of the
// main display. Divided by the scale factor of the screen, its center becomes a point in the
// global display space of Core Graphics, which counts in points from the same corner:
//
let position = monitor.position();
let size = monitor.size();
let scale_factor = monitor.scale_factor();
let center = CGPoint {
x: (f64::from(position.x) + f64::from(size.width) / 2.0) / scale_factor,
y: (f64::from(position.y) + f64::from(size.height) / 2.0) / scale_factor,
};
let (mut display, mut count) = (0u32, 0u32);
if unsafe { CGGetDisplaysWithPoint(center, 1, &mut display, &mut count) } != 0 || count == 0 {
return None;
}
let size_mm = unsafe { CGDisplayScreenSize(display) };
Some((size_mm.width, size_mm.height))
}
/// The physical size of the screen the window shows on, in millimeters, as the monitor reports it.
///
/// GDK looks the screen up the same way Tao does for the monitor: by the window. GTK may only be
/// called on the main thread, which is where the callers run.
#[cfg(target_os = "linux")]
fn screen_size_mm(window: &WebviewWindow, _monitor: &Monitor) -> Option<(f64, f64)> {
use gtk::prelude::*;
let gtk_window = window.gtk_window().ok()?;
let gdk_window = gtk_window.window()?;
let monitor = gtk_window.display().monitor_at_window(&gdk_window)?;
let (width_mm, height_mm) = (monitor.width_mm(), monitor.height_mm());
if width_mm <= 0 || height_mm <= 0 {
return None;
}
Some((f64::from(width_mm), f64::from(height_mm)))
}
/// Other systems report no physical size we know how to read, so their screens count as of unknown size.
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
fn screen_size_mm(_window: &WebviewWindow, _monitor: &Monitor) -> Option<(f64, f64)> {
None
}
#[cfg(test)]
mod tests {
use super::*;
/// Sets the given size on the given screen, with the given custom scale in percent.
fn set(policy: &mut ZoomPolicy, screen: Option<Screen>, size: InterfaceSize, custom_percent: Option<u32>) {
let custom_scale = custom_percent.map(|percent| f64::from(percent) / 100.0);
policy.screens.insert(screen.unwrap().key(), ScreenPolicy { size, custom_scale });
}
/// The given size on the given screen alone.
fn policy(screen: Option<Screen>, size: InterfaceSize) -> ZoomPolicy {
let mut policy = ZoomPolicy::default();
set(&mut policy, screen, size, None);
policy
}
/// The custom size with the given scale on the given screen alone.
fn custom(screen: Option<Screen>, percent: u32) -> ZoomPolicy {
let mut policy = ZoomPolicy::default();
set(&mut policy, screen, InterfaceSize::Custom, Some(percent));
policy
}
/// A 13.3-inch screen in the ratio of 16:10, with 1920 pixels across.
fn laptop(system_scale: f64) -> Option<Screen> {
Some(Screen { device_width: 1920.0, device_height: 1200.0, system_scale, size_mm: Some((286.5, 179.1)) })
}
/// A 14-inch screen in the ratio of 16:10, with 1920 pixels across.
fn fourteen_inch_laptop(system_scale: f64) -> Option<Screen> {
Some(Screen { device_width: 1920.0, device_height: 1200.0, system_scale, size_mm: Some((301.6, 188.5)) })
}
/// The 14-inch screen of a MacBook Pro, which macOS lays out with 1512 points across.
fn macbook_pro() -> Option<Screen> {
Some(Screen { device_width: 3024.0, device_height: 1964.0, system_scale: 2.0, size_mm: Some((302.4, 196.4)) })
}
/// A 27-inch monitor with 3840 pixels across, at a scaling of 150 percent.
fn external_monitor() -> Option<Screen> {
Some(Screen { device_width: 3840.0, device_height: 2160.0, system_scale: 1.5, size_mm: Some((597.0, 336.0)) })
}
/// A screen which does not report its size, with 1920 pixels across at a scaling of 150 percent.
fn screen_of_unknown_size() -> Option<Screen> {
Some(Screen { device_width: 1920.0, device_height: 1080.0, system_scale: 1.5, size_mm: None })
}
fn assert_close(actual: f64, expected: f64) {
assert!((actual - expected).abs() < 0.01, "expected about {expected}, got {actual}");
}
/// The density the screen shows AI Studio with after the zoom of the report, in CSS pixels per inch.
fn css_pixels_per_inch(screen: Option<Screen>, report: &InterfaceSizeReport) -> f64 {
let screen = screen.unwrap();
screen.device_pixels_per_inch().unwrap() / (screen.system_scale * report.zoom)
}
#[test]
fn the_app_default_shows_every_laptop_with_the_reference_density() {
for screen in [laptop(1.0), laptop(1.5), fourteen_inch_laptop(1.5), macbook_pro()] {
let report = report_for(&policy(screen, InterfaceSize::AppDefault), screen);
assert_close(css_pixels_per_inch(screen, &report), REFERENCE_CSS_PIXELS_PER_INCH);
}
}
#[test]
fn the_app_default_shows_a_14_inch_laptop_at_120_percent() {
let report = report_for(&policy(fourteen_inch_laptop(1.5), InterfaceSize::AppDefault), fourteen_inch_laptop(1.5));
assert_eq!((report.system_percent, report.app_default_percent), (150, 120));
assert_close(report.zoom, 0.8);
}
#[test]
fn the_app_default_shows_a_13_inch_laptop_at_126_percent() {
let report = report_for(&policy(laptop(1.5), InterfaceSize::AppDefault), laptop(1.5));
assert_eq!(report.app_default_percent, 126);
assert_close(report.zoom, 1.26 / 1.5);
}
#[test]
fn the_app_default_shows_a_macbook_pro_at_188_percent() {
let report = report_for(&policy(macbook_pro(), InterfaceSize::AppDefault), macbook_pro());
assert_eq!(report.app_default_percent, 188);
assert_close(report.zoom, 0.94);
}
#[test]
fn the_app_default_keeps_the_system_scaling_on_an_external_monitor() {
let report = report_for(&policy(external_monitor(), InterfaceSize::AppDefault), external_monitor());
assert_close(report.zoom, 1.0);
assert_eq!(report.app_default_percent, 150);
}
#[test]
fn the_app_default_keeps_the_system_scaling_on_a_screen_of_unknown_size() {
let report = report_for(&policy(screen_of_unknown_size(), InterfaceSize::AppDefault), screen_of_unknown_size());
assert_close(report.zoom, 1.0);
}
#[test]
fn the_system_scaling_ignores_the_screen() {
let report = report_for(&policy(laptop(1.5), InterfaceSize::SystemScaling), laptop(1.5));
assert_close(report.zoom, 1.0);
assert_eq!(report.current_percent, 150);
}
#[test]
fn a_custom_scale_counts_like_the_scaling_of_the_system() {
let report = report_for(&custom(laptop(1.5), 120), laptop(1.5));
assert_close(report.zoom, 0.8);
assert_eq!(report.current_percent, 120);
assert_eq!(report.size, InterfaceSize::Custom);
}
#[test]
fn every_screen_keeps_a_size_of_its_own() {
let mut zoom_policy = policy(laptop(1.5), InterfaceSize::AppDefault);
set(&mut zoom_policy, external_monitor(), InterfaceSize::Custom, Some(175));
let on_the_laptop = report_for(&zoom_policy, laptop(1.5));
assert_eq!((on_the_laptop.size, on_the_laptop.current_percent), (InterfaceSize::AppDefault, 126));
let on_the_monitor = report_for(&zoom_policy, external_monitor());
assert_eq!((on_the_monitor.size, on_the_monitor.current_percent), (InterfaceSize::Custom, 175));
}
#[test]
fn a_screen_keeps_its_custom_scale_while_another_size_applies() {
let mut policy = ZoomPolicy::default();
set(&mut policy, laptop(1.5), InterfaceSize::SystemScaling, Some(120));
assert_eq!(report_for(&policy, laptop(1.5)).current_percent, 150);
}
#[test]
fn a_laptop_the_user_set_nothing_for_gets_the_app_default() {
let report = report_for(&custom(external_monitor(), 175), laptop(1.5));
assert_eq!((report.size, report.default_size), (InterfaceSize::AppDefault, InterfaceSize::AppDefault));
assert_eq!(report.current_percent, 126);
assert_close(report.zoom, 1.26 / 1.5);
}
#[test]
fn a_large_screen_the_user_set_nothing_for_keeps_the_system_scaling() {
for screen in [external_monitor(), screen_of_unknown_size()] {
let report = report_for(&custom(laptop(1.5), 120), screen);
assert_eq!((report.size, report.default_size), (InterfaceSize::SystemScaling, InterfaceSize::SystemScaling));
assert_close(report.zoom, 1.0);
}
}
#[test]
fn a_custom_size_without_a_scale_shows_as_with_the_app_default() {
let report = report_for(&policy(laptop(1.5), InterfaceSize::Custom), laptop(1.5));
assert_eq!(report.current_percent, 126);
}
#[test]
fn until_the_app_sends_the_sizes_every_screen_shows_in_its_default_size() {
let on_the_laptop = report_for(&POLICY.lock().unwrap(), laptop(1.5));
assert_eq!((on_the_laptop.size, on_the_laptop.current_percent), (InterfaceSize::AppDefault, 126));
let on_the_monitor = report_for(&POLICY.lock().unwrap(), external_monitor());
assert_eq!((on_the_monitor.size, on_the_monitor.current_percent), (InterfaceSize::SystemScaling, 150));
}
#[test]
fn the_key_of_a_screen_names_its_resolution_and_size_but_not_its_scaling() {
assert_eq!(laptop(1.5).unwrap().key(), "1920x1200@287x179mm");
assert_eq!(laptop(1.0).unwrap().key(), laptop(1.5).unwrap().key());
assert_eq!(screen_of_unknown_size().unwrap().key(), "1920x1080");
}
#[test]
fn a_laptop_allows_what_keeps_the_layout_wide_and_the_text_readable() {
// At most 1920 / 1024 = 1.875, at least a density of 170 / 200 = 0.85 -- on the steps of five:
let report = report_for(&ZoomPolicy::default(), laptop(1.5));
assert_eq!((report.min_percent, report.max_percent), (90, 185));
}
#[test]
fn a_macbook_pro_allows_a_range_of_its_own() {
let report = report_for(&ZoomPolicy::default(), macbook_pro());
assert_eq!((report.min_percent, report.max_percent), (130, 295));
}
#[test]
fn a_large_monitor_allows_the_whole_range() {
let report = report_for(&ZoomPolicy::default(), external_monitor());
assert_eq!((report.min_percent, report.max_percent), (85, 300));
}
#[test]
fn a_screen_of_unknown_size_allows_half_of_the_system_scaling_at_least() {
let report = report_for(&ZoomPolicy::default(), screen_of_unknown_size());
assert_eq!((report.min_percent, report.max_percent), (75, 185));
}
#[test]
fn the_smallest_scale_of_all_is_not_lost_to_a_rounding_error() {
// A 24-inch monitor with 1920 pixels across allows less than 60 percent for readability:
let low_density_monitor = Some(Screen { device_width: 1920.0, device_height: 1080.0, system_scale: 1.0, size_mm: Some((531.0, 299.0)) });
assert_eq!(report_for(&ZoomPolicy::default(), low_density_monitor).min_percent, 60);
}
#[test]
fn a_custom_scale_beyond_the_limits_of_a_screen_is_brought_within_them() {
assert_eq!(report_for(&custom(laptop(1.0), 300), laptop(1.0)).current_percent, 185);
assert_eq!(report_for(&custom(macbook_pro(), 60), macbook_pro()).current_percent, 130);
}
#[test]
fn a_tiny_screen_lets_the_layout_win_over_the_readable_text() {
let tiny_screen = Some(Screen { device_width: 800.0, device_height: 600.0, system_scale: 1.0, size_mm: Some((100.0, 60.0)) });
let report = report_for(&ZoomPolicy::default(), tiny_screen);
assert!(report.min_percent <= report.max_percent, "min {} above max {}", report.min_percent, report.max_percent);
}
#[test]
fn without_a_screen_nothing_is_zoomed() {
let policy = custom(laptop(1.0), 150);
assert_close(report_for(&policy, None).zoom, 1.0);
let implausible_screen = Some(Screen { device_width: f64::NAN, device_height: 1080.0, system_scale: 1.0, size_mm: None });
assert_close(report_for(&policy, implausible_screen).zoom, 1.0);
}
/// The app sends the size in PascalCase, see RustEnumConverter in the app.
#[test]
fn the_request_reads_the_names_the_app_sends() {
let request: SetInterfaceSizeRequest = serde_json::from_value(serde_json::json!({
"screens": [
{ "screen_key": "1920x1200@287x179mm", "size": "Custom", "custom_percent": 150 },
{ "screen_key": "3840x2160@597x336mm", "size": "SystemScaling", "custom_percent": null },
],
})).unwrap();
let on_the_laptop = &request.screens[0];
assert_eq!(on_the_laptop.screen_key, "1920x1200@287x179mm");
assert_eq!((on_the_laptop.size, on_the_laptop.custom_percent), (InterfaceSize::Custom, Some(150)));
let on_the_monitor = &request.screens[1];
assert_eq!((on_the_monitor.size, on_the_monitor.custom_percent), (InterfaceSize::SystemScaling, None));
}
/// The app reads the report as flat fields next to success and error_message, and the sizes in PascalCase.
#[test]
fn the_response_names_the_fields_as_the_app_reads_them() {
let response = InterfaceSizeResponse { success: true, error_message: String::new(), report: report_for(&ZoomPolicy::default(), laptop(1.5)) };
let json = serde_json::to_value(response).unwrap();
for field in ["success", "error_message", "zoom", "size", "default_size", "screen_key", "screen_width", "screen_height", "screen_diagonal_inches", "system_percent", "current_percent", "app_default_percent", "min_percent", "max_percent"] {
assert!(json.get(field).is_some(), "the response lacks the field {field}");
}
assert_eq!(json["size"], "AppDefault");
}
}
+1
View File
@@ -26,3 +26,4 @@ mod sidecar_types;
pub mod tokenizer;
mod file_actions;
pub mod global_shortcuts;
pub mod interface_zoom;
+2
View File
@@ -55,6 +55,8 @@ fn create_router() -> Router {
.route("/updates/install", get(crate::app_window::install_update))
.route("/app/exit", post(crate::app_window::exit_app))
.route("/app/restart", post(crate::app_window::restart_app))
.route("/app/interface-size", get(crate::interface_zoom::get_interface_size))
.route("/app/interface-size", post(crate::interface_zoom::set_interface_size))
.route("/select/directory", post(crate::file_actions::select_directory))
.route("/select/file", post(crate::file_actions::select_file))
.route("/select/files", post(crate::file_actions::select_files))