【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その7 macOS ネイティブメニュー・多言語対応・GitHub Actions でのリリース
2026.10.04

どもです。
R-CHR の解説もいよいよ最終回です。ここまでで CHR の読み書き、UI、描画ツール、PNG インポートと、エディタとしての機能は一通り解説してきました。
最終回は「egui だけでは足りなかった部分をどう埋めたか」という話で、macOS のネイティブメニューバー、日本語 / 英語の切り替え、そして GitHub Actions で Windows / macOS / Linux 向けにビルドして配布する仕組みをまとめた形になります。
作って動くところまでは楽しいのですが、「他の人が普通に使える形で配る」となると途端にやることが増えるんですよね。。
連載の目次
- 概要とアーキテクチャ
- NES の CHR フォーマット(2BPP)と iNES ヘッダ、パレット
- egui で組む 3 パネル UI とバンクビュー
- ドットエディタの基礎とアンドゥ設計
- 図形ツール(Bresenham・楕円・塗りつぶし・スタンプ)
- PNG インポートと 3 つのマッピング戦略
- macOS ネイティブメニュー・多言語対応・GitHub Actions でのリリース(この記事)
macOS ではネイティブメニューバーを使いたい
egui にはメニューバーのウィジェット(egui::menu::bar)があって、Windows や Linux ではそれで十分です。ただ macOS の場合、メニューはウィンドウの中ではなく画面上部のメニューバーにあるのが当たり前でして、ウィンドウ内に egui のメニューが出ていると、なんというか「Mac のアプリではない感」がすごい。
あと、Cmd+S や Cmd+Z のようなショートカットも、macOS ではメニュー項目に紐づいたアクセラレータとして扱われるのが自然です。
ということで、macOS だけ muda クレートでネイティブ NSMenu を組み、それ以外の OS は egui のメニューバー、と切り替えています。
// ── メニューバー (macOS はネイティブメニューを使うため非表示) #[cfg(not(target_os = "macos"))] self.show_menu_bar(ctx);
native_menu.rs は先頭に #![cfg(target_os = "macos")] を付けてファイルごと macOS 専用にし、Cargo.toml でも依存を [target.'cfg(target_os = "macos")'.dependencies] に入れているので、他の OS では muda や objc2 がそもそもビルドされません。
初期化タイミングの制約
muda で NSMenu を組んでアプリに登録するには menu.init_for_nsapp() を呼ぶのですが、これは NSApplication が生成された後でないと動きません。そして NSApplication を作っているのは eframe の run_native() の中です。
なので、初期化は run_native() に渡すアプリ生成クロージャの中で行います。第 3 回の main.rs にあったこれですね。
eframe::run_native(
"R-CHR",
options,
Box::new(|cc| {
// macOS: NSApp が初期化された後(ここ)でネイティブメニューを構築し外観を設定する
#[cfg(target_os = "macos")]
{
native_menu::init();
native_menu::set_app_appearance(true); // デフォルトはダークモード
}
// ...
}),
)
run_native() の前で呼ぶと NSApp が無くて落ちる、というのを最初にやらかしました。
MenuItem は Send ではない
もうひとつの罠が、muda の MenuItem が内部で Rc を使っていて Send ではないことです。メニュー項目のハンドル(あとで有効 / 無効を切り替えたりテキストを変えたりするために必要)をグローバルに持ちたいのですが、static や OnceLock には Send + Sync が要求されるので入れられません。
そこで thread_local! + RefCell です。
struct MenuHandles {
about: MenuItem,
lang_english: CheckMenuItem,
file_new: MenuItem,
file_open: MenuItem,
// ...
view_dark_mode: CheckMenuItem,
// サブメニュー(言語切替時に set_text するため保持)
sub_file: Submenu,
sub_edit: Submenu,
sub_view: Submenu,
sub_palette: Submenu,
}
thread_local! {
static HANDLES: RefCell<<option>> = const { RefCell::new(None) };
}
メニューはメインスレッドからしか触らないので、スレッドローカルで持つのは理にかなっています。
先頭はアプリ名メニュー
macOS のメニューバーは、最初のサブメニューが必ずアプリ名のメニュー(About や Quit が入るやつ)として扱われるというルールがあります。これを知らずにファイルメニューを先頭に置くと、「ファイル」の中身がアプリ名メニューに吸い込まれます。
// ── macOS: 先頭はアプリ名メニュー(省略するとファイルメニューがそこに入る)
let app_menu = Submenu::new("R-CHR", true);
app_menu.append(&h.about).unwrap();
app_menu.append(&PredefinedMenuItem::separator()).unwrap();
app_menu.append(&h.lang_english).unwrap();
app_menu.append(&PredefinedMenuItem::separator()).unwrap();
app_menu.append(&PredefinedMenuItem::quit(None)).unwrap();
// ── ルートメニューに追加してアプリのメニューバーへ
let menu = Menu::new();
menu.append(&app_menu).unwrap();
menu.append(&h.sub_file).unwrap();
menu.append(&h.sub_edit).unwrap();
menu.append(&h.sub_view).unwrap();
menu.append(&h.sub_palette).unwrap();
menu.init_for_nsapp();
ショートカットは Accelerator で項目に付けます。
file_save: MenuItem::new(s.file_save, false, Some(Accelerator::new(Some(cmd), Code::KeyS))),
イベントの受け取り
muda のメニューはクリックされると MenuEvent::receiver() のチャネルにイベントを流します。R-CHR ではこれを毎フレーム try_recv() でポーリングして、項目 ID を照合して自前の MenuAction enum に変換しています。
pub fn try_recv_action() -> Option {
let event = MenuEvent::receiver().try_recv().ok()?;
HANDLES.with(|slot| {
let borrow = slot.borrow();
let h = borrow.as_ref()?;
let id = &event.id;
if id == h.about.id() { Some(MenuAction::About) }
else if id == h.file_new.id() { Some(MenuAction::FileNew) }
else if id == h.file_open.id() { Some(MenuAction::FileOpen) }
// ...
else if id == h.view_dark_mode.id() { Some(MenuAction::ViewDarkMode(h.view_dark_mode.is_checked())) }
else { None }
})
}
受け取り側は update() の先頭で、キューが空になるまで回してディスパッチします。
pub(in crate::editor) fn handle_native_menu(&mut self, ctx: &egui::Context) {
while let Some(action) = native_menu::try_recv_action() {
match action {
MenuAction::About => self.show_about = true,
MenuAction::FileNew => self.new_file(),
MenuAction::FileOpen => self.open_file(),
MenuAction::FileSave => { if let Err(e) = self.save_file() { self.error_msg = Some(e); } }
MenuAction::EditUndo => self.do_undo(),
MenuAction::ViewDarkMode(v) => {
self.dark_mode = v;
native_menu::set_app_appearance(v);
}
MenuAction::LangEnglish(en) => {
self.lang = if en { Lang::En } else { Lang::Ja };
native_menu::set_menu_lang(self.lang);
}
// ...
}
}
// enabled / checked 状態を毎フレーム同期
let has_tile = self.selected_tile.is_some()
&& self.rom.as_ref().map_or(false, |r| !r.chr_data().is_empty());
native_menu::sync_state(
self.file_path.is_some() && self.is_modified,
!self.undo_stack.is_empty(),
has_tile,
has_tile && self.tile_clipboard.is_some(),
self.dark_mode,
self.lang,
);
}
native_menu が返すのはあくまで「何が押されたか」だけで、アプリの状態は一切知りません。アプリ側のディスパッチは editor/mac/menu_events.rs に置いてあります。
enabled / checked の同期
「未保存の変更が無いときは保存をグレーアウト」「アンドゥスタックが空なら元に戻すをグレーアウト」といった状態は、毎フレーム sync_state() で egui 側の状態からメニューへ書き込む形にしています。
pub fn sync_state(can_save: bool, can_undo: bool, can_copy: bool, can_paste: bool, dark_mode: bool, lang: Lang) {
HANDLES.with(|slot| {
let borrow = slot.borrow();
let Some(h) = borrow.as_ref() else { return };
h.file_save.set_enabled(can_save);
h.edit_undo.set_enabled(can_undo);
h.edit_copy.set_enabled(can_copy);
h.edit_paste.set_enabled(can_paste);
if h.view_dark_mode.is_checked() != dark_mode {
h.view_dark_mode.set_checked(dark_mode);
}
// ...
});
}
即時モードの発想をネイティブメニューにも持ち込んだ形ですね。「状態が変わったらメニューを更新する」のではなく「毎フレーム状態をメニューに流し込む」。イベント駆動で更新漏れを気にするより、こっちのほうが圧倒的に楽でした。
キーボードショートカットの二重定義
ここでひとつ注意点。macOS で NSMenu にアクセラレータを付けると、Cmd+Z や Cmd+S は NSMenu が先に横取りして、egui には届きません。なので第 3 回で見た keyboard.rs の Cmd+Z 処理は、macOS ではメニュー経由で動いています。
keyboard.rs にも同じ処理を残しているのは、NSMenu が機能しない状況へのフォールバックです。二重に実行される心配は無いので(届くのはどちらか一方だけ)、そのままにしてあります。
ダークモードと NSAlert
タイトルバーの色まで含めてダークモードにするには、egui の Visuals だけでは足りず、NSApplication の外観を設定する必要があります。objc2-app-kit で NSAppearance を切り替えています。
pub fn set_app_appearance(dark: bool) {
let Some(mtm) = MainThreadMarker::new() else { return };
let app = NSApplication::sharedApplication(mtm);
let name = if dark {
unsafe { NSAppearanceNameDarkAqua }
} else {
unsafe { NSAppearanceNameAqua }
};
let appearance = NSAppearance::appearanceNamed(name);
unsafe { app.setAppearance(appearance.as_deref()); }
}
未保存の変更があるときの確認ダイアログも、macOS では NSAlert を直接呼んでいます。
pub fn unsaved_changes_dialog(file_name: &str, lang: Lang) -> UnsavedChoice {
let Some(mtm) = MainThreadMarker::new() else {
return UnsavedChoice::Cancel;
};
let s = crate::editor::i18n::t(lang);
let alert = unsafe { NSAlert::new(mtm) };
unsafe {
alert.setMessageText(&NSString::from_str(s.unsaved_title));
alert.setInformativeText(&NSString::from_str(&lang.fmt_unsaved_body(file_name)));
alert.setAlertStyle(NSAlertStyle::Warning);
alert.addButtonWithTitle(&NSString::from_str(s.save_and_close));
alert.addButtonWithTitle(&NSString::from_str(s.discard_btn));
alert.addButtonWithTitle(&NSString::from_str(s.cancel_btn2));
let response = alert.runModal();
// NSAlertFirstButtonReturn=1000, Second=1001, Third=1002
match response {
1000 => UnsavedChoice::Save,
1001 => UnsavedChoice::Discard,
_ => UnsavedChoice::Cancel,
}
}
}
objc2 経由だと Objective-C の API がほぼそのまま Rust から呼べます。unsafe だらけにはなりますが、MainThreadMarker で「メインスレッドからしか呼べない」ことが型で保証されるのは、さすが Rust だなぁと。
macOS 以外では、同じ確認を rfd::MessageDialog の Yes / No / Cancel で出しています。
日本語 / 英語の切り替え
UI の文言は日本語と英語を切り替えられます。仕組みはとても素朴で、全ての静的文字列を 1 つの構造体にまとめて、言語ごとに static インスタンスを持つだけです。
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum Lang { #[default] Ja, En }
/// UI で使う全静的文字列のセット
pub struct Strings {
pub about: &'static str,
pub menu_file: &'static str,
pub file_new: &'static str,
pub file_open: &'static str,
// ...(60 個ほど)
}
static JA: Strings = Strings {
about: "R-CHR について…",
menu_file: "ファイル",
file_new: "新規作成",
file_open: "開く…",
// ...
};
static EN: Strings = Strings { /* ... */ };
pub fn t(lang: Lang) -> &'static Strings {
match lang { Lang::Ja => &JA, Lang::En => &EN }
}
使う側は self.t().file_open のように書きます。全部 &'static str なので実行時のアロケーションはゼロですし、構造体のフィールドなので文言を 1 つ追加したら両言語に追加しないとコンパイルエラーになるのが地味に便利です。翻訳漏れが構造的に起きません。
数値やファイル名を埋め込む文言は、Lang のメソッドとして用意しています。
impl Lang {
pub fn fmt_png_done(self, tw: usize, th: usize) -> String { /* ... */ }
pub fn fmt_unsaved_body(self, file_name: &str) -> String { /* ... */ }
pub fn fmt_approx_colors(self, n: usize) -> String { /* ... */ }
// ...
}
前回の PngWarning を enum にした話も、この構造に乗せるためでした。
言語を切り替えたときは、egui 側は次のフレームで勝手に新しい文言で描き直されますが、ネイティブメニューは自分で更新する必要があります。それが set_menu_lang() で、保持しているハンドル全部に set_text() を呼んで回ります。サブメニューのハンドルまで MenuHandles に持たせているのはこのためです。
GitHub Actions で 3 OS 向けにビルドしてリリース
最後に配布の話です。main に push すると、Windows / macOS / Linux の 3 つでビルドして、GitHub Releases に成果物を並べるところまで自動化しています。
name: Build & Release
on:
push:
branches:
- main
jobs:
build:
name: Build (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
artifact_name: R-CHR
asset_name: R-CHR-linux-x86_64
asset_path: R-CHR-linux-x86_64
- os: windows-latest
artifact_name: R-CHR.exe
asset_name: R-CHR-windows-x86_64.exe
asset_path: R-CHR-windows-x86_64.exe
- os: macos-latest
artifact_name: R-CHR
asset_name: R-CHR-macos-aarch64
asset_path: R-CHR-macos-aarch64.zip
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Install Linux system dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
libxcb-render0-dev \
libxcb-shape0-dev \
libxcb-xfixes0-dev \
libxkbcommon-dev \
libssl-dev \
libgtk-3-dev
- name: Build
run: cargo build --release
fail-fast: false にしているのは、1 つの OS でコケても他の OS のビルドは最後まで走らせたいからです。Linux だけ apt-get で X11 / GTK 系のヘッダを入れているのは、eframe(winit)と rfd(GTK のファイルダイアログ)のビルドに必要なためですね。ここ、ローカルの Mac では一切気にしなくて良いので、CI で初めて気づくやつです。
macos-latest は現在 Apple Silicon のランナーなので、成果物は aarch64 になります。
macOS は .app バンドルにする
Windows と Linux は cargo build で出てきたバイナリをそのまま置けば良いのですが、macOS はそうもいかず、.app バンドルの形にしないとダブルクリックで起動できません(アイコンも付きません)。
- name: Package (macOS .app bundle)
if: matrix.os == 'macos-latest'
run: |
APP="R-CHR.app"
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
# ディレクトリ構造
mkdir -p "$APP/Contents/MacOS"
mkdir -p "$APP/Contents/Resources"
# バイナリ
cp target/release/R-CHR "$APP/Contents/MacOS/R-CHR"
# Info.plist(バージョンを埋め込む)
sed "s/{{VERSION}}/$VERSION/g" macos/Info.plist > "$APP/Contents/Info.plist"
# icon.png → AppIcon.icns
ICONSET="AppIcon.iconset"
mkdir -p "$ICONSET"
for size in 16 32 128 256 512; do
sips -z $size $size assets/icon.png --out "$ICONSET/icon_${size}x${size}.png"
sips -z $((size * 2)) $((size * 2)) assets/icon.png --out "$ICONSET/icon_${size}x${size}@2x.png"
done
iconutil -c icns "$ICONSET" -o "$APP/Contents/Resources/AppIcon.icns"
rm -rf "$ICONSET"
# ad-hoc 署名(Gatekeeper の「壊れている」エラーを回避)
codesign --force --deep --sign - "$APP"
# ditto で zip(パーミッション・拡張属性を保持)
ditto -c -k --keepParent "$APP" ${{ matrix.asset_path }}
やっていることを順に。
- ディレクトリ構造:
.appは実体としてはただのディレクトリで、Contents/MacOS/にバイナリ、Contents/Resources/にアイコン、Contents/Info.plistにメタ情報、という決まった構造です - Info.plist:リポジトリに
{{VERSION}}というプレースホルダ入りのテンプレートを置いておき、Cargo.tomlから取ったバージョンをsedで埋めます。バージョンの管理場所をCargo.toml一箇所にするためです - アイコン:
assets/icon.pngからsipsで各サイズの PNG を作って.iconsetディレクトリに並べ、iconutilで.icnsに固めます。macOS 標準ツールだけで完結します - ad-hoc 署名:ここが重要でして、署名なしの
.appを zip で配ると、受け取った側で「壊れているため開けません」と言われます。Apple Developer の証明書は無くても、codesign --sign -の ad-hoc 署名をしておくだけでこのエラーは回避できます(「開発元を確認できません」の警告は出るので、右クリック → 開く、は必要です) - ditto で zip:普通の
zipコマンドだと実行権限や拡張属性が落ちてしまうことがあるので、macOS のditto -c -k --keepParentで固めます
Info.plist はこんなテンプレートです。
CFBundleName
R-CHR
CFBundleDisplayName
R-CHR
CFBundleIdentifier
com.rchr.app
CFBundleVersion
{{VERSION}}
CFBundleShortVersionString
{{VERSION}}
CFBundleExecutable
R-CHR
CFBundlePackageType
APPL
CFBundleIconFile
AppIcon
NSHighResolutionCapable
LSMinimumSystemVersion
11.0
NSHighResolutionCapable を入れておかないと Retina でボケボケになるので、これは必須です。
Release ジョブ
3 OS のビルドが終わったら、別ジョブで成果物を集めて Release を作ります。
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Get version from Cargo.toml
id: version
run: |
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/')
echo "tag=v${VERSION}" >> $GITHUB_OUTPUT
echo "name=v${VERSION}" >> $GITHUB_OUTPUT
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
merge-multiple: true
- name: Create or update Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.name }}
files: artifacts/*
fail_on_unmatched_files: true
make_latest: true
タグ名も Cargo.toml のバージョンから作っています。v0.1.0 のようなタグの Release が無ければ作り、あれば成果物を更新する、という動きです。なのでリリースしたいときは Cargo.toml の version を上げて push するだけ。タグを手で打つ必要はありません。
permissions: contents: write を忘れると Release の作成で権限エラーになるので、ここも CI で初めて気づくやつですね。
連載のまとめ
7 回にわたって R-CHR の中身を解説してきました。振り返ると、設計上の判断で効いたのはこの辺りかなと思います。
- 層を分ける:
model → io → editorの一方通行にして、CHR の変換や PNG のマッピングを egui から切り離した。おかげで純粋なデータ処理として書けて、あとから読み返しても分かりやすい - 書き込みの出口を 1 つにする:全ツールの書き込みが
encode_dot()、アンドゥの入口がpush_undo_batch()。ツールを足すのが「ドット座標のリストを作る関数を書く」だけになった - UI とデータ更新を分ける:
EditorActionで「何をするか」だけを返して、描画後に適用する。即時モード + 借用チェッカーと仲良くやる定石 - 即時モードの発想を外にも広げる:ネイティブメニューの enabled / checked も「毎フレーム状態を流し込む」にしたら、更新漏れを考えなくて良くなった
- 配布まで含めて自動化する:
.appバンドルと ad-hoc 署名まで CI に入れておいたので、Cargo.tomlのバージョンを上げるだけでリリースできる
Rust + egui でデスクトップアプリを配布用としてきちんと作るのは初めてだったのですが、思っていたより素直に組めた、というのが正直な感想です。特にドットエディタのような「状態から絵を描き直す」タイプのアプリと即時モードの相性は本当に良いので、同じようなツールを作ろうとしている方には結構おすすめできるのかと。
当初は「YY-CHR の代わりに Mac で使えるものがあれば」くらいの気持ちで始めたのですが、気がついたら描画ツールが 10 個になり、PNG インポートに 3 つも戦略が付き、macOS の NSAlert まで呼んでいました。まぁ、こういうものですよねw
リポジトリはこちらです。バグ報告や要望などあれば Issue にいただけると嬉しいです。
ではではぁ。
またまたぁ。

















