mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-04 18:27:21 +00:00
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:
1 parent
c2b62c6dbd
commit
e0337a99bc
27 files changed
+1742
-27
No files matched your search
Generated
+1
@@ -4297,6 +4297,7 @@ dependencies = [
|
||||
"file-format",
|
||||
"flexi_logger",
|
||||
"futures",
|
||||
"gtk",
|
||||
"hmac 0.13.0",
|
||||
"image",
|
||||
"keyring-core",
|
||||
|
||||
+5
-4
@@ -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
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,4 @@ mod sidecar_types;
|
||||
pub mod tokenizer;
|
||||
mod file_actions;
|
||||
pub mod global_shortcuts;
|
||||
pub mod interface_zoom;
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user