画面のスケール倍率(DPI)を取得する

scaleFactor() で拡大率を取得し、物理ピクセルと論理ピクセルを換算する。onScaleChanged() で倍率の変化を検知する方法と、devicePixelRatio との使い分けも示す。

ウィンドウ 対象: Tauri 2.x 更新日: 読了目安: 約10分 win-021
目次
  1. 前提条件
  2. 論理ピクセルと物理ピクセルの関係
  3. 1. フロントエンドから実装する (TypeScript)
  4. 倍率を取得して換算する
  5. 倍率の変化を検知する
  6. canvas を倍率に合わせて描き直す
  7. 2. バックエンドから実装する (Rust)
  8. 動作確認
  9. よくあるエラーと対処法
  10. OS ごとの違いと注意点
  11. 関連レシピ

Windows の表示スケールを 150% にしたり Retina ディスプレイを使ったりすると、1 論理ピクセルが複数の物理ピクセルで描かれます。この比率がスケール倍率で、JS の scaleFactor()、Rust の scale_factor() で取得します。ウィンドウの API には物理ピクセルを返すものと論理ピクセルで指定するものが混在しているので、両者の換算に倍率が欠かせません。倍率はウィンドウを別のモニターへ動かしたときや OS の設定を変えたときに変わるため、変化の検知もあわせて説明します。

前提条件

プラグインは不要です。scaleFactor() の権限 core:window:allow-scale-factor は core:window:default に、onScaleChanged()(イベントの購読)は core:event:default に含まれるので、core:default があれば追加は要りません。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": ["core:default"]
}

論理ピクセルと物理ピクセルの関係

物理ピクセル = 論理ピクセル × 倍率 です。論理ピクセルはページの CSS ピクセルと同じ大きさ(ページのズームが 100% のとき)で、倍率が違うモニターでも見た目の大きさが揃います。

表示スケール倍率論理 800 x 600 の物理サイズ
100%1800 x 600
125%1.251000 x 750
150%1.51200 x 900
200%(Retina など)21600 x 1200

Tauri の API では、ウィンドウの位置・サイズの読み取り、onResized() / onMoved() の値、cursorPosition()、モニター情報が物理ピクセルで、設定ファイルと生成オプションの値が論理ピクセルです。API ごとの一覧は ウィンドウのサイズを変更する と ウィンドウの位置を指定する、モニターごとの倍率は 接続されているモニター情報を取得する にあります。

倍率はアプリ全体ではなくウィンドウごとの値で、そのウィンドウがいるモニターの倍率です。

換算には端数が出ます。125% で論理 801 を物理に直すと 1001.25 です。JS の toPhysical() / toLogical() は丸めないので、PhysicalSize / PhysicalPosition として API に渡す前に Math.round() で整数にします。Rust の to_physical::<u32>() は四捨五入します。論理と物理を行き来させると 1px ずれることがあるので、値の比較には ±1 の幅を持たせます。

1. フロントエンドから実装する (TypeScript)

倍率を取得して換算する

import { getCurrentWindow, LogicalSize, PhysicalSize } from '@tauri-apps/api/window';

const win = getCurrentWindow();

// 論理 → 物理。Physical* は整数で渡す必要があるので丸める
export function toPhysicalSize(size: LogicalSize, factor: number): PhysicalSize {
  const p = size.toPhysical(factor);
  return new PhysicalSize(Math.round(p.width), Math.round(p.height));
}

export async function showScale() {
  const factor = await win.scaleFactor(); // 例: 1.25
  const inner = await win.innerSize(); // 物理ピクセル
  const logical = inner.toLogical(factor); // 論理ピクセル
  const p = toPhysicalSize(new LogicalSize(801, 600), factor);
  console.log(`scale ${factor} / dpr ${window.devicePixelRatio}`);
  console.log(`physical ${inner.width}x${inner.height} / logical ${Math.round(logical.width)}x${Math.round(logical.height)} / css ${window.innerWidth}x${window.innerHeight}`);
  console.log(`801x600 -> ${p.width}x${p.height}`);
}

倍率の変化を検知する

onScaleChanged() は、倍率の違うモニターへウィンドウを動かしたとき、OS の表示スケールや画面の解像度を変えたときに呼ばれます。値には新しい倍率と、変化後の内側の物理サイズが入ります。同じ倍率のモニター間の移動では呼ばれません。

よくある失敗は、起動時に 1 回だけ読んだ倍率を使い回すことです。別のモニターへ移ると換算がすべてずれるので、保持するなら変化のたびに更新します。もう 1 つ、payload.size は型の上では PhysicalSize ですが、実際には width / height だけのオブジェクトで toLogical() を持っていません(onResized() の値とは違います)。new PhysicalSize(payload.size) で包み直してから使います。

import { getCurrentWindow, PhysicalSize } from '@tauri-apps/api/window';

const win = getCurrentWindow();
let factor = await win.scaleFactor(); // 変化したら更新する

export function currentFactor(): number {
  return factor;
}

export const stopWatching = await win.onScaleChanged(({ payload }) => {
  factor = payload.scaleFactor;
  const size = new PhysicalSize(payload.size); // メソッドを使えるように包み直す
  const logical = size.toLogical(payload.scaleFactor);
  console.log(`scale ${factor} / physical ${size.width}x${size.height} / logical ${Math.round(logical.width)}x${Math.round(logical.height)}`);
});

canvas を倍率に合わせて描き直す

canvas をくっきり描くには、内部の解像度を「表示サイズ × window.devicePixelRatio」にします。devicePixelRatio はページのズームの影響を受けることがあり、scaleFactor() は受けません。ページ内の描画には devicePixelRatio、ウィンドウの API の換算には scaleFactor() と使い分けます。devicePixelRatio の変化はメディアクエリで検知できます。

// 内部の解像度を「表示サイズ(CSS ピクセル)× devicePixelRatio」にして描き直す
function render(canvas: HTMLCanvasElement) {
  const dpr = window.devicePixelRatio;
  const { width, height } = canvas.getBoundingClientRect();
  canvas.width = Math.round(width * dpr);
  canvas.height = Math.round(height * dpr);
  const ctx = canvas.getContext('2d');
  if (!ctx) return;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 以降は CSS ピクセルの座標で描ける
  ctx.font = '14px sans-serif';
  ctx.fillText(`devicePixelRatio = ${dpr}`, 12, 24);
}

// devicePixelRatio が変わるたびに呼ぶ。条件は今の値で作り直す
function watchPixelRatio(onChange: () => void) {
  const mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
  mq.addEventListener('change', () => {
    onChange();
    watchPixelRatio(onChange);
  }, { once: true });
}

const canvas = document.querySelector<HTMLCanvasElement>('#chart');
if (canvas) {
  render(canvas);
  watchPixelRatio(() => render(canvas));
}

2. バックエンドから実装する (Rust)

scale_factor() は Window / WebviewWindow から呼べます。倍率の変化は on_window_event の WindowEvent::ScaleFactorChanged で受け取れ、scale_factor と new_inner_size(物理ピクセル)が入っています。この列挙子は将来の項目追加に備えた形なので、パターンの最後に .. が必要です。次の例では、倍率と論理サイズをまとめて呼び出し元のフロントエンドへ送ります。

use tauri::{Emitter, LogicalSize, PhysicalSize, WindowEvent};

#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct ScaleInfo {
    scale_factor: f64,
    physical: (u32, u32),
    logical: (f64, f64),
}

fn scale_info(factor: f64, size: PhysicalSize<u32>) -> ScaleInfo {
    let logical: LogicalSize<f64> = size.to_logical(factor);
    ScaleInfo {
        scale_factor: factor,
        physical: (size.width, size.height),
        logical: (logical.width, logical.height),
    }
}

/// 呼び出し元ウィンドウの倍率と内側のサイズを返す
#[tauri::command]
fn get_scale_info(window: tauri::WebviewWindow) -> Result<ScaleInfo, String> {
    let factor = window.scale_factor().map_err(|e| e.to_string())?;
    let size = window.inner_size().map_err(|e| e.to_string())?;
    Ok(scale_info(factor, size))
}

/// 論理サイズを物理ピクセルに直す(Rust の換算は四捨五入される)
#[tauri::command]
fn logical_to_physical(window: tauri::WebviewWindow, width: f64, height: f64) -> Result<(u32, u32), String> {
    let factor = window.scale_factor().map_err(|e| e.to_string())?;
    let p: PhysicalSize<u32> = LogicalSize::new(width, height).to_physical(factor);
    Ok((p.width, p.height))
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .on_window_event(|window, event| {
            if let WindowEvent::ScaleFactorChanged { scale_factor, new_inner_size, .. } = event {
                // 倍率が変わったウィンドウにだけ送る
                let _ = window.emit_to(window.label(), "scale-info", scale_info(*scale_factor, *new_inner_size));
            }
        })
        .invoke_handler(tauri::generate_handler![get_scale_info, logical_to_physical])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
import { getCurrentWindow } from '@tauri-apps/api/window';

type ScaleInfo = { scaleFactor: number; physical: [number, number]; logical: [number, number] };

console.log(await invoke<ScaleInfo>('get_scale_info'));
console.log(await invoke<[number, number]>('logical_to_physical', { width: 801, height: 600 })); // 125% なら [1001, 750]
await getCurrentWindow().listen<ScaleInfo>('scale-info', ({ payload }) => console.log('changed', payload));

動作確認

npm run tauri dev で、幅 1024・高さ 768 のウィンドウを拡大率 125% の Windows で開き、showScale() を呼ぶと次のように出ます。物理ピクセルを倍率で割った値が CSS ピクセルと一致し、801 x 600 の換算は丸められています。

scale 1.25 / dpr 1.25
physical 1280x960 / logical 1024x768 / css 1024x768
801x600 -> 1001x750

次に拡大率 100% のモニターへドラッグすると、onScaleChanged() のログに scale 1 と新しい物理サイズが出ます。多くの場合、見た目の大きさが保たれるよう物理サイズが変わり、論理サイズはほぼ同じです。canvas の文字も新しい倍率で描き直されます。

よくあるエラーと対処法

  • onScaleChanged() の中で「toLogical is not a function」という趣旨の TypeError: payload.size にはメソッドがありません。new PhysicalSize(payload.size) で包み直します。
  • PhysicalSize を渡したら引数の変換に失敗した趣旨のエラーになる: toPhysical() の結果に小数が残っています。Rust 側は整数で受けるので Math.round() してから渡します。
  • 別のモニターへ移ると位置やサイズの計算がずれる: 起動時の倍率を使い回しています。onScaleChanged() で更新するか、計算の直前に scaleFactor() を読みます。
  • devicePixelRatio と scaleFactor() が一致しない: ページがズームされています。ウィンドウの API の換算には scaleFactor() を使います。
  • 「window.scale_factor not allowed」で始まるエラー: capability から core:default(または core:window:default)を外しています。core:window:allow-scale-factor を追加します。

OS ごとの違いと注意点

  • Windows: 設定アプリ(コントロールパネル)で表示スケールを変えると、開いているウィンドウにも onScaleChanged() が届きます。
  • ページのズーム: zoomHotkeysEnabled: true のとき、Windows では WebView2 標準のズーム操作が有効になり、macOS と Linux では Ctrl / Cmd と - / = で 20% 刻み(20%〜1000%)に拡大縮小するスクリプトが入ります。macOS と Linux ではこの機能に core:webview:allow-set-webview-zoom 権限が要ります。iOS / Android では使えません。どの OS でもズームは scaleFactor() に影響しません。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する