ウィンドウの位置を指定する(起動時の初期位置・実行中の移動)

起動時の位置は tauri.conf.json の x / y、実行中は setPosition()(権限 core:window:allow-set-position)で指定する。外枠と内側の座標の違いと、画面外に出たときの戻し方も示す。

ウィンドウ 対象: Tauri 2.x 更新日: 読了目安: 約11分 win-005
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 実行中に動かす
  4. 前回の位置で開く(画面外チェック付き)
  5. 新しいウィンドウを親の隣に開く
  6. 2. バックエンドから実装する (Rust)
  7. 動作確認
  8. よくあるエラーと対処法
  9. OS ごとの違いと注意点
  10. 関連レシピ

「ツールパレットをメインウィンドウの右隣に開く」「前回閉じた場所で開く」など、ウィンドウの位置をアプリ側で決めたい場面があります。起動時の位置は tauri.conf.json の x / y(JS や Rust で作るウィンドウは生成時のオプション)、実行中の移動は JS の setPosition() か Rust の set_position() で指定します。迷いやすいのは、指定に使う座標(論理ピクセル)と読み取れる座標(物理ピクセル)の単位の違いです。モニターを外したあとにウィンドウが画面外に取り残される問題の対処も説明します。

前提条件

プラグインは不要です。移動に使う core:window:allow-set-position は core:window:default に含まれないので追加します。位置を読む outerPosition() や availableMonitors() は core:window:default に、移動の通知 onMoved() は core:event:default に含まれます。後述の例のために core:window:allow-show と、JS からウィンドウを作る core:webview:allow-create-webview-window も足しています。

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

起動時の位置は設定ファイルで決めます(権限は不要)。x / y は論理ピクセルで、外枠(タイトルバーを含む)の左上を指します。x と y は両方書かないと無視されます。center: true を併記すると中央配置が優先され、x / y は「どのモニターの中央か」の判定に使われます(ウィンドウを画面中央に配置する)。

{
  "app": {
    "windows": [
      { "label": "main", "title": "My App", "width": 1024, "height": 720, "x": 80, "y": 60 }
    ]
  }
}

座標の単位と基準は API ごとに決まっています。読み取った物理ピクセルを論理ピクセルの引数に渡すと、拡大率 150% の環境では 1.5 倍の位置へ飛びます(換算の考え方は 画面のスケール倍率(DPI)を取得する)。

値単位基準
設定ファイル・生成オプションの x / y、Rust の position()論理外枠の左上
setPosition() / set_position() の引数渡したクラス次第外枠の左上
outerPosition()物理外枠の左上
innerPosition()物理描画領域(ページ)の左上

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

実行中に動かす

setPosition() には LogicalPosition か PhysicalPosition を渡します。現在の位置から相対的に動かすときは、読み取った物理ピクセルを倍率で論理ピクセルに直してから足します。

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

const win = getCurrentWindow();

// 現在の位置から論理ピクセルで dx, dy だけ動かす
export async function moveBy(dx: number, dy: number) {
  const factor = await win.scaleFactor();
  const pos = (await win.outerPosition()).toLogical(factor); // 物理 → 論理
  await win.setPosition(new LogicalPosition(pos.x + dx, pos.y + dy)); // core:window:allow-set-position が必要
}

setPosition() の直後の outerPosition() は、環境によっては移動前の値を返します。確定した位置は onMoved() で受け取ります。

前回の位置で開く(画面外チェック付き)

保存には outerPosition() を使います。innerPosition() を保存して setPosition() で戻すと、基準が外枠と描画領域で食い違い、起動のたびにタイトルバーの高さ分ずつ下へずれます。また、外部モニターを外すと保存した座標がどのモニターにも乗らなくなるので、復元前にタイトルバー付近がどれかの作業領域に入っているかを確かめます。タイトルバーさえ見えていれば、ユーザーは自分で動かせます。

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

const KEY = 'window-position';
const win = getCurrentWindow();

// 移動が落ち着いたら、外枠の位置を物理ピクセルのまま保存する
export async function rememberPosition() {
  let timer: number | undefined;
  return win.onMoved(() => {
    window.clearTimeout(timer); // ドラッグ中は連続で届くので 300ms まとめる
    timer = window.setTimeout(async () => {
      // 最小化・最大化中は通常表示の位置ではないので保存しない
      if ((await win.isMinimized()) || (await win.isMaximized())) return;
      const { x, y } = await win.outerPosition();
      localStorage.setItem(KEY, JSON.stringify({ x, y }));
    }, 300);
  });
}

// タイトルバー付近(左上から右へ 100、下へ 20)がどれかの作業領域に入っているか
async function isReachable(x: number, y: number): Promise<boolean> {
  const px = x + 100;
  const py = y + 20;
  return (await availableMonitors()).some(({ workArea: { position: p, size: s } }) =>
    px >= p.x && px < p.x + s.width && py >= p.y && py < p.y + s.height);
}

// 起動時に呼ぶ(tauri.conf.json で "visible": false にしておく)
export async function restorePosition() {
  try {
    const saved = localStorage.getItem(KEY);
    if (saved) {
      const { x, y } = JSON.parse(saved) as { x: number; y: number };
      if (await isReachable(x, y)) await win.setPosition(new PhysicalPosition(x, y));
    }
  } finally {
    await win.show(); // 例外が起きても必ず表示する。core:window:allow-show が必要
  }
}

倍率の違うモニターが混在する環境では、同じ座標でも換算に使われる倍率によって復元位置がずれることがあります。大きさも含めて確実に復元したいなら Window State プラグイン を使います。

新しいウィンドウを親の隣に開く

JS で作るウィンドウは WebviewWindow のオプションに x / y(論理ピクセル)を渡します。親の outerPosition() / outerSize() は物理ピクセルなので、倍率で直してから計算します。画面の角やトレイアイコンの近くに置くだけなら Positioner プラグイン が手軽です。

import { currentMonitor, getCurrentWindow } from '@tauri-apps/api/window';
import { WebviewWindow } from '@tauri-apps/api/webviewWindow';

const PALETTE_W = 280;
const GAP = 8;

export async function openPalette() {
  const existing = await WebviewWindow.getByLabel('palette');
  if (existing) return existing; // 同じラベルのウィンドウは 2 つ作れない

  const parent = getCurrentWindow();
  const factor = await parent.scaleFactor();
  const pos = (await parent.outerPosition()).toLogical(factor);
  const size = (await parent.outerSize()).toLogical(factor);

  let x = pos.x + size.width + GAP;
  const monitor = await currentMonitor();
  if (monitor) {
    const area = monitor.workArea; // 物理ピクセル
    const right = (area.position.x + area.size.width) / monitor.scaleFactor;
    if (x + PALETTE_W > right) x = pos.x - PALETTE_W - GAP; // はみ出すなら左隣へ
  }

  const palette = new WebviewWindow('palette', {
    url: '/palette.html',
    title: 'パレット',
    width: PALETTE_W,
    height: 480,
    x,
    y: pos.y, // x と y は両方指定する
    parent,   // 親ウィンドウに従属させる(挙動は OS ごとに異なる)
  });
  palette.once('tauri://created', () => console.log('palette opened'));
  palette.once('tauri://error', (e) => console.error('create failed', e.payload));
  return palette;
}

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

set_position() は LogicalPosition と PhysicalPosition のどちらも受け取ります。Rust で作るウィンドウは WebviewWindowBuilder の position(x, y)(論理ピクセル)で初期位置を決め、そのコマンドは async にします(理由は「OS ごとの違い」を参照)。setup の画面外チェックは、開発機のサブモニターの座標を設定ファイルに書いたまま、モニター 1 台の環境で起動したときの保険です。移動の通知は WindowEvent::Moved で受け取れます。

use tauri::{LogicalPosition, Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};

/// 呼び出し元のウィンドウを論理座標の位置へ動かす
#[tauri::command]
fn move_window(window: tauri::WebviewWindow, x: f64, y: f64) -> Result<(), String> {
    window.set_position(LogicalPosition::new(x, y)).map_err(|e| e.to_string())
}

/// 呼び出し元の右隣にパレットを開く(ウィンドウを作るコマンドは async にする)
#[tauri::command]
async fn open_palette(app: tauri::AppHandle, window: tauri::WebviewWindow) -> Result<(), String> {
    if app.get_webview_window("palette").is_some() {
        return Ok(());
    }
    let factor = window.scale_factor().map_err(|e| e.to_string())?;
    let pos = window.outer_position().map_err(|e| e.to_string())?.to_logical::<f64>(factor);
    let size = window.outer_size().map_err(|e| e.to_string())?.to_logical::<f64>(factor);
    WebviewWindowBuilder::new(&app, "palette", WebviewUrl::App("palette.html".into()))
        .title("パレット")
        .inner_size(280.0, 480.0)
        .position(pos.x + size.width + 8.0, pos.y) // 論理ピクセル
        .build()
        .map_err(|e| e.to_string())?;
    Ok(())
}

/// タイトルバー付近がどのモニターの作業領域にも無ければ中央へ戻す
fn ensure_reachable(window: &tauri::WebviewWindow) -> tauri::Result<()> {
    let pos = window.outer_position()?; // 物理ピクセル
    let (px, py) = (pos.x + 100, pos.y + 20);
    let reachable = window.available_monitors()?.iter().any(|m| {
        let a = m.work_area();
        px >= a.position.x
            && px < a.position.x + a.size.width as i32
            && py >= a.position.y
            && py < a.position.y + a.size.height as i32
    });
    if !reachable {
        window.center()?;
    }
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            if let Some(main) = app.get_webview_window("main") {
                ensure_reachable(&main)?;
            }
            Ok(())
        })
        .on_window_event(|window, event| {
            if let WindowEvent::Moved(pos) = event {
                // 物理ピクセル。ドラッグ中は連続で届く
                println!("{} moved to ({}, {})", window.label(), pos.x, pos.y);
            }
        })
        .invoke_handler(tauri::generate_handler![move_window, open_palette])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

await invoke('move_window', { x: 100, y: 100 });
await invoke('open_palette');

動作確認

npm run tauri dev で起動し、次の関数をボタンなどから呼んで値を確かめます。

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

export async function logPosition() {
  const win = getCurrentWindow();
  const factor = await win.scaleFactor();
  const outer = await win.outerPosition(); // 物理ピクセル
  const inner = await win.innerPosition();
  const logical = outer.toLogical(factor);
  console.log(`outer ${outer.x},${outer.y} / inner ${inner.x},${inner.y} / scale ${factor} / logical ${Math.round(logical.x)},${Math.round(logical.y)}`);
}

拡大率 150% の Windows で move_window に { x: 100, y: 100 } を渡してから呼ぶと、outer は 150,150、logical は 100,100 になります。inner は枠とタイトルバーの分だけ右下になります。rememberPosition() と restorePosition() を組み込んで再起動すると同じ場所で開き、保存した座標が画面外なら設定ファイルの位置で開きます。

よくあるエラーと対処法

  • 「window.set_position not allowed. Permissions associated with this command: core:window:allow-set-position」: 権限の追加漏れです。この詳しい文面はデバッグビルドのもので、リリースビルドでは「Command plugin:window|set_position not allowed by ACL」だけになります。追加して tauri dev を再起動します。
  • 起動するたびにウィンドウが少しずつ下へずれる: innerPosition() の値を setPosition() で戻しています。保存には outerPosition() を使います。
  • 設定ファイルの x が効かない: y を書いていないと両方とも無視されます。生成オプションも同じです。
  • ウィンドウが画面外に出て見えない: 座標が今のモニター構成の外にあります。preventOverflow は大きさを縮めるだけで位置は直さないので、上記の画面外チェックを入れます。
  • 「a window with label palette already exists」: 同じラベルのウィンドウは 1 つしか作れません。getByLabel() で確かめてから作ります。

OS ごとの違いと注意点

  • 座標の原点: デスクトップ座標の原点は、Windows と macOS ではメインモニターの左上、Linux の X11 では一番左にあるモニターの左上です。メインモニターより左や上に置いたモニターでは、座標が負の値になります。
  • Windows: ウィンドウを作る処理を同期コマンドやイベントハンドラーから呼ぶと固まることがあります。Rust で作るなら async コマンドにします。
  • 親ウィンドウ(parent): Windows では常に親より手前に表示され、親の最小化・終了に連動します。Linux では親に従属するウィンドウ、macOS では子ウィンドウになります。
  • 倍率の混在: 拡大率の違うモニターへ移すと物理サイズが変わるため、位置が少しずれて見えることがあります。モニターを指定して動かす方法は 接続されているモニター情報を取得する を参照してください。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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