🪟

Tauriアプリにウィンドウマネージャーを実装する

2024/12/26に公開

Tauriを使ってアプリのウィンドウのサイズや位置を管理できるようにしました。
具体的には、ウィンドウのサイズを増減させたり、画面の特定の位置に移動させる機能を実装します。

前提設定

tauri.conf.json

参考にしてみてください。
tauri.conf.json

tauri.conf.json
{
  "build": {
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run build",
    "devPath": "http://localhost:1420",
    "distDir": "../dist"
  },
  "package": {
    "productName": "Flanker",
    "version": "0.0.0"
  },
  "tauri": {
    "macOSPrivateApi": true,
    "allowlist": {
      "all": false,
      "http": {
        "all": true
      },
      "fs": {
        "all": true,
        "scope": ["$HOME/.config/flk/**"]
      },
      "path": {
        "all": true
      },
      "os": {
        "all": true
      },
      "shell": {
        "all": false,
        "open": true
      },
      "window": {
        "all": false,
        "close": true,
        "hide": true,
        "show": true,
        "maximize": true,
        "minimize": true,
        "unmaximize": true,
        "unminimize": true,
        "startDragging": true,
        "setAlwaysOnTop": true
      },
      "clipboard": {
        "all": true,
        "writeText": true,
        "readText": true
      },
      "protocol": {
        "asset": true,
        "assetScope": ["$HOME/.config/flk/images", "$HOME/.config/flk/images/*"]
      },
      "globalShortcut": {
        "all": true
      }
    },
    "windows": [
      {
        "decorations": false,
        "transparent": true,
        "title": "Flanker",
        "width": 1500,
        "height": 1000,
        "minWidth": 768,
        "minHeight": 76,
        "hiddenTitle": true,
        "titleBarStyle": "Overlay",
        "resizable": true,
        "fullscreen": true,
        "fileDropEnabled": false
      }
    ],
    "security": {
      "csp": "default-src 'self'; img-src * asset: https://asset.localhost blob: date:; script-src 'self'; style-src 'self'; object-src 'none';"
    },
    "bundle": {
      "active": true,
      "targets": "all",
      "identifier": "com.flanker.app",
      "icon": [
        "icons/32x32.png",
        "icons/128x128.png",
        "icons/128x128@2x.png",
        "icons/icon.icns",
        "icons/icon.ico"
      ]
    }
  }
}

ウィンドウのサイズを変更する

まず、ウィンドウの高さと幅を増減させる関数をRustで実装します。

関数内では、現在のウィンドウサイズ (outer_size) とスケールファクター (scale_factor) を利用して、新しいサイズを計算しています。

let new_height = current_size.height as f64 / scale_factor + 20.0;

tauri.conf.jsonの設定で最小値は以下に設定しているので、減少させる関数の場合、コード上ではそれを元に制約条件を追加します。

  • .max(76.0) 最小高さ
  • .max(768.0) 最小幅

高さを増加させる関数 (⌘+ArrowDown)

// increase_height.rs
use tauri::Window;

#[tauri::command]
pub async fn increase_height(window: Window) {
    tauri::async_runtime::spawn(async move {
        let current_size = window.outer_size().unwrap();
        // scale_factorはウィンドウのスケールファクターを取得。
        // (スケールファクターは、ウィンドウの表示がどれだけ拡大されているかを示します。)
        let scale_factor = window.scale_factor().unwrap();
        window.set_size(tauri::Size::Logical(tauri::LogicalSize {
            width: current_size.width as f64 / scale_factor,
            height: current_size.height as f64 / scale_factor + 20.0,
        })).unwrap();
    }).await.unwrap();
}

高さを減少させる関数 (⌘+ArrowUp)

// decrease_height.rs
use tauri::Window;

#[tauri::command]
pub async fn decrease_height(window: Window) {
    tauri::async_runtime::spawn(async move {
        let current_size = window.outer_size().unwrap();
        let scale_factor = window.scale_factor().unwrap();
        window.set_size(tauri::Size::Logical(tauri::LogicalSize {
            width: current_size.width as f64 / scale_factor,
            height: (current_size.height as f64 / scale_factor - 20.0).max(76.0),
        })).unwrap();
    }).await.unwrap();
}

幅を増加させる関数 (⌘+ArrowRight)

// increase_width.rs
use tauri::Window;

#[tauri::command]
pub async fn increase_width(window: Window) {
    tauri::async_runtime::spawn(async move {
        let current_size = window.outer_size().unwrap();
        let scale_factor = window.scale_factor().unwrap();
        let monitor = window.primary_monitor().unwrap().unwrap();
        let monitor_width = monitor.size().width as f64 / scale_factor;
        let new_width = (current_size.width as f64 / scale_factor + 20.0).min(monitor_width);
        window.set_size(tauri::Size::Logical(tauri::LogicalSize {
            width: new_width,
            height: current_size.height as f64 / scale_factor,
        })).unwrap();
    }).await.unwrap();
}

幅を減少させる関数 (⌘+ArrowLeft)

// decrease_width.rs
use tauri::Window;

#[tauri::command]
pub async fn decrease_width(window: Window) {
    tauri::async_runtime::spawn(async move {
        let current_size = window.outer_size().unwrap();
        let scale_factor = window.scale_factor().unwrap();
        window.set_size(tauri::Size::Logical(tauri::LogicalSize {
            width: (current_size.width as f64 / scale_factor - 20.0).max(768.0),
            height: current_size.height as f64 / scale_factor,
        })).unwrap();
    }).await.unwrap();
}

ウィンドウの位置を変更する

次に、ウィンドウを画面の特定の位置に移動させる関数を実装します。

primary_monitorを使用してスクリーン全体のサイズを取得し、ウィンドウの新しい位置を計算し画面位置を特定します。

let screen_width = monitor.size().width as f64 / scale_factor;

今回は左上や右下など、特定の位置にウィンドウを動かす関数を用意しています。

左上に移動させる関数 (⌘+U)

// move_window_top_left
use tauri::Window;

#[tauri::command]
pub fn move_window_top_left(window: Window) {
    window.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x: 0, y: 0 })).unwrap();
}

右上に移動させる関数 (⌘+I)

// move_window_top_right
use tauri::Window;

#[tauri::command]
pub fn move_window_top_right(window: Window) {
    let screen = window.primary_monitor().unwrap();
    let screen_width = screen.as_ref().map_or(0, |s| s.size().width as i32);
    let window_size = window.outer_size().unwrap();
    let window_width = window_size.width as i32;
    window.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x: screen_width - window_width, y: 0 })).unwrap();
}

左下に移動させる関数 (⌘+J)

// move_window_bottom_left
use tauri::Window;

#[tauri::command]
pub fn move_window_bottom_left(window: Window) {
    let screen = window.primary_monitor().unwrap();
    let screen_height = screen.as_ref().map_or(0, |s| s.size().height as i32);
    let window_size = window.outer_size().unwrap();
    let window_height = window_size.height as i32;
    window.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x: 0, y: screen_height - window_height })).unwrap();
}

右下に移動させる関数 (⌘+K)

// move_window_bottom_right
use tauri::Window;

#[tauri::command]
pub fn move_window_bottom_right(window: Window) {
    let screen = window.primary_monitor().unwrap();
    let screen_width = screen.as_ref().map_or(0, |s| s.size().width as i32);
    let screen_height = screen.as_ref().map_or(0, |s| s.size().height as i32);
    let window_size = window.outer_size().unwrap();
    let window_width = window_size.width as i32;
    let window_height = window_size.height as i32;
    window.set_position(tauri::Position::Physical(tauri::PhysicalPosition { x: screen_width - window_width, y: screen_height - window_height })).unwrap();
}

ウィンドウ操作ボタンの実装

ボタンの無効化やスタイリングに現在のウィンドウサイズを利用しています。

variant={window.innerHeight > 76 ? "fit" : "disabled"}
// App.tsx

// windowの高さと幅を変更する
const decreaseHeight = () =>
invokeTauriCommand("decrease_height", { window: appWindow });
const increaseHeight = () =>
invokeTauriCommand("increase_height", { window: appWindow });
const decreaseWidth = () =>
invokeTauriCommand("decrease_width", { window: appWindow });
const increaseWidth = () =>
invokeTauriCommand("increase_width", { window: appWindow });

// windowの位置を変更する
const moveWindowTopLeft = () => invokeTauriCommand("move_window_top_left");
const moveWindowTopRight = () => invokeTauriCommand("move_window_top_right");
const moveWindowBottomLeft = () =>
invokeTauriCommand("move_window_bottom_left");
const moveWindowBottomRight = () =>
invokeTauriCommand("move_window_bottom_right");

...

<Button
  variant={window.innerHeight > 76 ? "fit" : "disabled"}
  size={"fit"}
  onClick={decreaseHeight}
  className={`flex cursor-default items-center justify-center rounded p-0.5  ${window.innerHeight > 82 ? "hover:bg-white hover:text-black" : ""}`}
>
  <FoldVertical className="h-4 w-4" />
</Button>
<Button
  variant={"fit"}
  size={"fit"}
  onClick={increaseHeight}
  className="flex cursor-default items-center justify-center rounded p-0.5 hover:bg-white hover:text-black"
>
  <UnfoldVertical className="h-4 w-4 rotate-180" />
</Button>
<Button
  variant={window.innerWidth > 768 ? "fit" : "disabled"}
  size={"fit"}
  onClick={decreaseWidth}
  className={`flex cursor-default items-center justify-center rounded p-0.5  ${window.innerWidth > 768 ? "hover:bg-white hover:text-black" : ""}`}
>
  <FoldVertical className="h-4 w-4 rotate-90" />
</Button>
<Button
  variant={"fit"}
  size={"fit"}
  onClick={increaseWidth}
  className="flex cursor-default items-center justify-center rounded p-0.5 hover:bg-white hover:text-black"
>
  <UnfoldVertical className="h-4 w-4 -rotate-90" />
</Button>

<Button
  variant={"fit"}
  size={"fit"}
  onClick={moveWindowTopLeft}
  className="flex cursor-default items-center justify-center rounded p-0.5 hover:bg-white hover:text-black"
>
  <SquareArrowUpLeft
    strokeWidth={1.6}
    className="h-4 w-4"
  />
</Button>
<Button
  variant={"fit"}
  size={"fit"}
  onClick={moveWindowTopRight}
  className="flex cursor-default items-center justify-center rounded p-0.5 hover:bg-white hover:text-black"
>
  <SquareArrowUpRight
    strokeWidth={1.6}
    className="h-4 w-4"
  />
</Button>
<Button
  variant={"fit"}
  size={"fit"}
  onClick={moveWindowBottomLeft}
  className="flex cursor-default items-center justify-center rounded p-0.5 hover:bg-white hover:text-black"
>
  <SquareArrowDownLeft
    strokeWidth={1.6}
    className="h-4 w-4"
  />
</Button>
<Button
  variant={"fit"}
  size={"fit"}
  onClick={moveWindowBottomRight}
  className="flex cursor-default items-center justify-center rounded p-0.5 hover:bg-white hover:text-black"
>
  <SquareArrowDownRight
    strokeWidth={1.6}
    className="h-4 w-4"
  />
</Button>

完成形

ショートカット実装

useEffectフック内でkeydownイベントを検知し、特定のキー入力に応じてウィンドウ操作を実行します。

if (event.key === "ArrowDown" && event.metaKey) increaseHeight();
// App.tsx
  useEffect(() => {
    let keydownTimeout: NodeJS.Timeout | null = null;

    const handleKeydown = (event: KeyboardEvent) => {
      if (keydownTimeout) return;

      if (event.key === "ArrowDown" && event.metaKey) increaseHeight();
      if (event.key === "ArrowUp" && event.metaKey) decreaseHeight();
      if (event.key === "ArrowRight" && event.metaKey) increaseWidth();
      if (event.key === "ArrowLeft" && event.metaKey) decreaseWidth();
      if (event.key === "u" && event.metaKey) moveWindowTopLeft();
      if (event.key === "i" && event.metaKey) moveWindowTopRight();
      if (event.key === "j" && event.metaKey) moveWindowBottomLeft();
      if (event.key === "k" && event.metaKey) moveWindowBottomRight();

      keydownTimeout = setTimeout(() => {
        keydownTimeout = null;
      }, 10);
    };

    window.addEventListener("keydown", handleKeydown);

    return () => {
      window.removeEventListener("keydown", handleKeydown);
    };
  }, []);

まとめ

左上以外の隅に移動するときの関数の計算方法を考えさせられました。
そもそもprimary_monitor()というのが見つかりませんでした。
https://docs.rs/tauri/latest/tauri/struct.App.html#method.primary_monitor
ショートカットとかはユーザーが柔軟に変えれたりするUI用意してあげると良いかなと思いました。
あと、バグが残っててデュアルモニター(dellの32inch)使ってる時に幅を画面にいっぱいに広げられない(20pxくらい残る)。
Retinaとそうじゃないモニターで違った処理にする必要があるのかもしれない。

Discussion