【R-CHR】Rust + egui でファミコン用スプライトエディタを作った話 その3 egui で組む 3 パネル UI とバンクビュー
2026.09.26

どもです。
前回は CHR の 2BPP フォーマットとパレットという、バイト列の話でした。今回からいよいよ egui の話に入ります。
「egui って即時モードだけど、ちゃんとしたエディタっぽい UI が組めるの?」というのは自分も最初に気になったところでして。結論から言うと、パネルレイアウト・スクロール・テクスチャ表示・ドラッグ & ドロップあたりは全部素直に組めました。
ということで、今回は R-CHR の 3 パネルレイアウト、バンクビュー(CHR 全体表示)、情報パネル、キーボード操作の実装をまとめた形になります。
連載の目次
- 概要とアーキテクチャ
- NES の CHR フォーマット(2BPP)と iNES ヘッダ、パレット
- egui で組む 3 パネル UI とバンクビュー(この記事)
- ドットエディタの基礎とアンドゥ設計
- 図形ツール(Bresenham・楕円・塗りつぶし・スタンプ)
- PNG インポートと 3 つのマッピング戦略
- macOS ネイティブメニュー・多言語対応・GitHub Actions でのリリース
即時モード GUI とは
egui は 即時モード(immediate mode) の GUI ライブラリです。
普通の GUI フレームワーク(保持モード)では、ボタンやパネルといったウィジェットのオブジェクトを作ってツリーに登録し、イベントが来たらコールバックで状態を変える、という作りになります。
即時モードはそうではなく、毎フレーム update() が呼ばれて、その中で「今の状態」から UI を全部描き直すというスタイルです。ボタンは if ui.button("保存").clicked() { ... } のように、描画とイベント判定が同じ行で完結します。
impl eframe::App for RChrApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// ここが毎フレーム呼ばれる。self の状態を見て UI を描く
}
}
ウィジェットの状態を保持するオブジェクトが無いので、アプリの状態は全部自前の構造体(R-CHR なら RChrApp)に持つことになります。「状態はここに全部ある」というのがはっきりしているので、ドットエディタのように「データが変わったら画面も変わる」が当たり前のアプリだと、むしろ考えることが減って楽です。
エントリポイント
main.rs は短いです。
fn main() -> eframe::Result {
let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("R-CHR")
.with_inner_size([1200.0, 720.0])
.with_icon(load_icon()),
..Default::default()
};
eframe::run_native(
"R-CHR",
options,
Box::new(|cc| {
// macOS: NSApp が初期化された後(ここ)でネイティブメニューを構築し外観を設定する
#[cfg(target_os = "macos")]
{
native_menu::init();
native_menu::set_app_appearance(true); // デフォルトはダークモード
}
egui_extras::install_image_loaders(&cc.egui_ctx);
editor::setup::setup_fonts(&cc.egui_ctx);
Ok(Box::new(editor::app::RChrApp::default()))
}),
)
}
eframe::run_native() にウィンドウ設定とアプリ生成クロージャを渡すだけ。クロージャの中でやっているのは 3 つです。
- macOS ネイティブメニューの初期化(最終回で解説)
egui_extras::install_image_loaders():ツールバーの SVG アイコンをegui::Imageで読めるようにするsetup_fonts():日本語フォントの登録
日本語フォントは自分で埋め込む
egui のデフォルトフォントには CJK が入っていないので、日本語 UI にするならフォントを自分で登録する必要があります。R-CHR では Noto Sans JP を include_bytes! で埋め込んでいます。
pub fn setup_fonts(ctx: &egui::Context) {
let mut fonts = egui::FontDefinitions::default();
// Noto Sans JP Regular — 本文フォント
fonts.font_data.insert(
"noto_regular".to_owned(),
egui::FontData::from_static(include_bytes!(
"../../assets/fonts/Noto_Sans_JP/static/NotoSansJP-Regular.ttf"
)).into(),
);
fonts.families.get_mut(&egui::FontFamily::Proportional).unwrap().insert(0, "noto_regular".to_owned());
fonts.families.get_mut(&egui::FontFamily::Monospace).unwrap().push("noto_regular".to_owned());
// Noto Sans JP Bold — bold_font named family
fonts.font_data.insert(
"noto_bold".to_owned(),
egui::FontData::from_static(include_bytes!(
"../../assets/fonts/Noto_Sans_JP/static/NotoSansJP-Bold.ttf"
)).into(),
);
fonts.families.insert(
egui::FontFamily::Name(theme::BOLD_FONT.into()),
vec!["noto_bold".to_owned()],
);
ctx.set_fonts(fonts);
}
egui にはフォントウェイトの概念が無いので、太字は「bold_font という名前のフォントファミリーを別に登録して、そのファミリーを指定する」形で実現しています。theme.rs に font_label() や rich_label() のようなヘルパーを用意して、セクション見出しは常にそれを通すようにしました。
update() の流れ
update() の中でやっていることを上から順に並べるとこうなります。
update() 呼び出し(1フレーム) │ ├── Visuals 設定(dark / light) ├── [macOS] handle_native_menu() … ネイティブメニューのイベント処理 ├── テクスチャ再生成(texture_dirty フラグ) ├── ウィンドウ閉じるリクエスト処理 … 未保存なら確認ダイアログ ├── タイトルバー更新(未保存変更を * で表示) ├── [非 macOS] show_menu_bar() … egui のメニューバー ├── 1px ボーダー(TopBottomPanel) ├── 右パネル: show_info_panel() ├── 中央パネル: show_dot_editor() … Option を返す ├── バンクビュー: show_bank_view() ├── EditorAction 適用(apply_action) ├── PNG インポートダイアログ ├── ドラッグ & ドロップ検知 ├── About ダイアログ(egui::Window) └── handle_keyboard()
app.rs にはこの骨格だけを書いて、各パネルの中身は bank_view.rs / dot_editor.rs / info_panel.rs の impl RChrApp に分けています。
3 パネルレイアウト
egui のパネルは SidePanel / TopBottomPanel / CentralPanel の 3 種類で、先に宣言したパネルから順に領域を確保していき、最後に CentralPanel が残りを全部取るというルールです。
R-CHR は「右に情報パネル、その左にドットエディタ、残りがバンクビュー」なので、右から順に宣言します。
// ── 右パネル(情報・描画色・パレット - 245px固定)
egui::SidePanel::right("info_panel")
.resizable(false)
.exact_width(245.0)
.frame(egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::symmetric(12, 8)).fill(theme::COL_PANEL_BG))
.show(ctx, |ui| {
self.show_info_panel(ui);
});
// ── 中央パネル(ドットエディタ)
let mut editor_action: Option = None;
let dot_max_w = (ctx.screen_rect().width() - 245.0 - theme::BANK_VIEW_MIN_W).max(180.0);
egui::SidePanel::right("dot_editor_panel")
.resizable(true)
.default_width(420.0)
.min_width(180.0)
.max_width(dot_max_w)
.frame(egui::Frame::side_top_panel(&ctx.style()).inner_margin(egui::Margin::ZERO))
.show(ctx, |ui| {
editor_action = self.show_dot_editor(ui);
});
// ── バンクビュー(メイン)
egui::CentralPanel::default()
.frame(egui::Frame::central_panel(&ctx.style()).inner_margin(egui::Margin::ZERO))
.show(ctx, |ui| {
// ...
self.show_bank_view(ui);
});
ドットエディタも SidePanel::right にして resizable(true) を付けているのがポイントで、これで境界をドラッグして幅を変えられます。max_width を「画面幅 − 情報パネル 245px − バンクビュー最低幅 410px」で計算しているのは、ドットエディタを広げすぎてバンクビューが潰れないようにするためです。
バンクビュー:CHR 全体をテクスチャで表示する
ここが今回のメインです。
前回の render_full_image() で CHR 全体を幅 128px の RGBA 画像にしました。これを毎フレーム作り直すのはさすがに無駄なので、GPU テクスチャとしてキャッシュして、変更があったフレームだけ再生成します。
if self.texture_dirty {
if let Some(rom) = &self.rom {
if !rom.chr_data().is_empty() {
let image = render_full_image(
rom.chr_data(),
&self.dat_palette,
self.selected_palette_set,
&self.master_palette,
);
self.bank_texture = Some(ctx.load_texture(
"bank_view",
image,
egui::TextureOptions::NEAREST,
));
}
}
self.texture_dirty = false;
}
texture_dirty フラグは、ドットを塗った・パレットを変えた・ファイルを開いた、といったタイミングで true にします。あとは update() の冒頭でこのブロックが拾ってくれる、という形です。
TextureOptions::NEAREST は必須です。デフォルトの線形補間だと拡大したときにドットがぼやけてしまって、ドット絵エディタとしては話にならないので。
整数倍スケール
表示倍率はウィンドウ幅に合わせて自動で決めますが、必ず整数倍にしています。
let available_w = ui.available_width(); let scale = (available_w / 128.0).floor().max(1.0); let tile_px = 8.0 * scale; // 1 タイルの表示サイズ(px)
128px 幅の画像を「利用可能幅 ÷ 128 の切り捨て」倍で表示する。中途半端な倍率だとドットの境界が滲むので、ここも NEAREST と同じ理由です。
ScrollArea の中にテクスチャを置く
スクロールは egui::ScrollArea::vertical() にお任せです。その中で画像サイズぶんの領域を allocate_exact_size() で確保し、painter.image() でテクスチャを貼ります。
let mut scroll_area = egui::ScrollArea::vertical()
.id_salt("bank_scroll")
.auto_shrink([false, false]);
if let Some(addr) = self.pending_scroll_addr.take() {
let row = addr / 0x100;
scroll_area = scroll_area.vertical_scroll_offset(row as f32 * tile_px);
}
scroll_area.show(ui, |ui| {
let (rect, response) = ui.allocate_exact_size(
egui::vec2(display_w, display_h),
egui::Sense::click(),
);
// テクスチャ描画
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0));
ui.painter().image(texture_id, rect, uv, egui::Color32::WHITE);
// ...
});
pending_scroll_addr は「次のフレームでここまでスクロールしてほしい」という予約で、アドレスジャンプや矢印キー移動で Some(アドレス) をセットしておくと、次の show_bank_view() で vertical_scroll_offset() に変換されて消費されます。「1 行 = 0x100 バイト」なので addr / 0x100 が行番号、それに tile_px を掛ければピクセル位置です。
グリッド線と選択ハイライト
テクスチャの上に、painter でグリッドと選択枠を重ねて描きます。
- タイル単位の細いグリッド(白の 10% くらいの半透明)
- フォーカスサイズ単位のやや濃いグリッド(16px 以上のとき)
- 選択ブロックの赤い枠線
// 選択ブロックのハイライト
if let Some(tile_idx) = selected_tile_snap {
let t_row = tile_idx / 16;
let t_col = tile_idx % 16;
let bx = rect.left() + t_col as f32 * tile_px;
let by_ = rect.top() + t_row as f32 * tile_px;
let bs = tile_px * n as f32;
let hl = egui::Rect::from_min_size(egui::pos2(bx, by_), egui::vec2(bs, bs));
painter.rect_stroke(
hl, 0.0,
egui::Stroke::new(2.0, egui::Color32::from_rgb(255, 80, 80)),
egui::StrokeKind::Outside,
);
}
即時モードなので、こういう「重ね描き」は本当にただ順番に描くだけです。テクスチャを貼って、その上に線を引く。以上。
クリックでタイルを選択
allocate_exact_size() に Sense::click() を渡しているので、返ってきた response でクリック判定ができます。クリック位置から rect の左上を引いて、tile_px で割れば行・列が出ます。
let new_tile = if response.clicked() {
response.interact_pointer_pos().and_then(|pos| {
let rel_x = pos.x - rect.left();
let rel_y = pos.y - rect.top();
if rel_x < 0.0 || rel_y < 0.0 { return None; }
let col = (rel_x / tile_px) as usize;
let row = (rel_y / tile_px) as usize;
if col >= 16 { return None; }
let global_tile = row * 16 + col;
(global_tile < total_tiles).then_some(global_tile)
})
} else {
None
};
選択されたタイルのグローバルインデックスを self.selected_tile に入れておくと、ドットエディタ側がそれを読んでブロックを表示する、という連携です。
スクロール位置を状態に書き戻す
ScrollArea::show() の戻り値からはスクロールオフセットや表示領域の高さが取れるので、これを self に書き戻しておきます。
let scroll_y = scroll_out.state.offset.y; self.scroll_addr = (scroll_y / tile_px) as usize * 0x100; // 矢印キーのスクロール判定用にビューポート情報を保存 self.scroll_top_row = (scroll_y / tile_px) as usize; self.visible_tile_rows = (scroll_out.inner_rect.height() / tile_px).floor() as usize;
scroll_addr は情報パネルの「現在アドレス」表示に、scroll_top_row / visible_tile_rows は矢印キーで選択タイルが画面外に出たときの自動スクロール判定に使います。
フォーカスサイズとアドレスジャンプ
バンクビュー上部のツールバーには、アドレス入力欄と 8 / 16 / 32 / 64 / 128 のフォーカスサイズ切り替えボタンがあります。
フォーカスサイズは enum で、値そのものをピクセル数にしてあります。
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum FocusSize {
S8 = 8,
S16 = 16,
S32 = 32,
S64 = 64,
S128 = 128,
}
impl FocusSize {
/// 1 辺のタイル数(例: S32 → 4)
pub(super) fn tile_count(self) -> usize { self as usize / 8 }
}
アドレスジャンプは 16 進文字列をパースして、対応するタイルにスクロール & フォーカスします。このとき、フォーカスサイズのグリッドにスナップさせているのがちょっとしたこだわりです。
pub(super) fn jump_to_address(&mut self) {
let raw = self.address_input.trim()
.trim_start_matches("0x")
.trim_start_matches("0X");
if let Ok(addr) = usize::from_str_radix(raw, 16) {
let total_tiles = self.rom.as_ref().map_or(0, |r| r.chr_data().len() / 16);
if total_tiles > 0 {
let tile_idx = (addr / 16).min(total_tiles.saturating_sub(1));
let n = self.focus_size.tile_count();
let snap_col = (tile_idx % 16 / n) * n;
let snap_row = (tile_idx / 16 / n) * n;
let snapped = snap_row * 16 + snap_col;
self.selected_tile = Some(snapped);
self.pending_scroll_addr = Some(snap_row * 0x100);
self.address_input = format!("{:06X}", snapped * 16);
return;
}
}
// パース失敗・範囲外の場合は現在値に戻す
// ...
}
例えばフォーカスが 32px(4×4 タイル)のときに中途半端なアドレスを入れても、4 タイル境界に揃った位置が選ばれます。
情報パネル:描画色とパレット編集
右の情報パネルには、現在アドレス、選択タイル番号、描画色 4 つ、パレット 4 セット、そして NES の 64 色一覧が並んでいます。
パレットの色を変える流れはこうです。
- パレットセットの色スウォッチをクリック →
editing_palette_cell = Some((set, color))にする - 下の NES 64 色グリッド(8×8)から色をクリック →
dat_palette.sets[set][color]にその NES インデックスを書き込む texture_dirty = trueにしてバンクビューを再描画
if let (Some(idx), Some((set_idx, color_idx))) = (selected_nes_idx, self.editing_palette_cell) {
self.dat_palette.sets[set_idx][color_idx] = idx;
self.texture_dirty = true;
self.editing_palette_cell = None;
}
スウォッチ類は全部 allocate_exact_size() + painter.rect_filled() で自前描画しています。egui に色ボタンウィジェットが無いわけではないのですが、ドット絵エディタっぽい見た目にしたかったので、24px の四角を並べる形にしました。ホバーすると 0x1A のように NES インデックスがツールチップで出ます。
キーボード操作
キー入力は ctx.input() の中でまとめて拾って、フラグに落としてから処理します。ctx.input() のクロージャ内で self を借用すると面倒なことになるので、いったん変数に逃がすのがコツです。
ctx.input(|i| {
let cmd = i.modifiers.ctrl || i.modifiers.mac_cmd;
if cmd && i.key_pressed(egui::Key::Z) {
do_undo = true;
} else if i.key_pressed(egui::Key::Z) {
new_palette_set = Some(0);
}
// ...
if cmd && i.key_pressed(egui::Key::S) {
if i.modifiers.shift { do_save_as = true; } else { do_save = true; }
}
// 矢印キー(フォーカスブロック単位で移動)
if i.key_pressed(egui::Key::ArrowRight) { d_col += 1; }
if i.key_pressed(egui::Key::ArrowLeft) { d_col -= 1; }
if i.key_pressed(egui::Key::ArrowDown) { d_row += 1; }
if i.key_pressed(egui::Key::ArrowUp) { d_row -= 1; }
});
Z / X / C / V 単独でパレットセット 0〜3 の切り替え、Cmd 付きだと Undo / Copy / Paste。左手のホームポジション付近だけでパレットを切り替えられるので、右手はマウスに置いたままで済みます。
矢印キーで選択タイルを動かしたときは、画面外に出たときだけスクロールさせます。
// 選択タイルが可視範囲外に出た場合のみスクロール
let visible_end = self.scroll_top_row + self.visible_tile_rows.max(1);
if new_row < self.scroll_top_row {
// 上に出た → 選択行を先頭に
self.pending_scroll_addr = Some(new_row * 0x100);
} else if new_row >= visible_end {
// 下に出た → 選択行が末尾に来るよう調整
let start_row = new_row + 1 - self.visible_tile_rows.max(1);
self.pending_scroll_addr = Some(start_row * 0x100);
}
// 可視範囲内なら scroll しない(チラツキ防止)
最初は毎回スクロールさせていたのですが、画面内で 1 タイル動かすたびにビューがガタガタ動いてしまって、これがなかなか不快でして。バンクビューから書き戻した scroll_top_row / visible_tile_rows を使って判定するようにしたら落ち着きました。
ドラッグ & ドロップ
ファイルの D&D は egui が標準で拾ってくれます。ctx.input() の raw.dropped_files にパスが入ってくるので、拡張子で振り分けるだけです。
let dropped = ctx.input(|i| {
i.raw.dropped_files.iter().find_map(|f| {
let path = f.path.as_ref()?;
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
Some((path.clone(), ext))
})
});
if let Some((path, ext)) = dropped {
match ext.as_str() {
"nes" | "bin" | "zip" => self.open_file_from_path(&path),
"png" | "bmp" => self.open_png_import_from_path(&path),
_ => {}
}
}
.nes を放り込めば開く、.png を放り込めばインポートダイアログが出る。これだけで体感がだいぶ変わります。
未保存の変更とウィンドウを閉じる処理
タイトルバーの * 表示と、閉じるときの確認ダイアログもここで。
// ── タイトルバー更新(未保存変更を * で表示)
let title = if self.is_modified {
format!("*{}", self.file_name.as_deref().unwrap_or(""))
} else {
format!("{}", self.file_name.as_deref().unwrap_or(""))
};
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
ウィンドウを閉じようとしたときは close_requested() で検知して、未保存ならいったん CancelClose を送ってダイアログを出し、「保存して閉じる」なら保存後に改めて Close を送ります。
if ctx.input(|i| i.viewport().close_requested()) {
if self.is_modified {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
// ... ダイアログを出して選択に応じて処理 ...
// 保存して閉じる → save_file() → Ok なら ViewportCommand::Close
// 保存せず閉じる → is_modified = false; ViewportCommand::Close
// キャンセル → 何もしない
}
}
ダイアログ自体は macOS では NSAlert、それ以外は rfd::MessageDialog を使い分けています。この辺りも最終回で。
まとめ
- egui は即時モード。毎フレーム
update()で状態から UI を全部描き直す - パネルは宣言順に領域を取るので、右から
SidePanel::rightを 2 つ並べて残りをCentralPanelに - CHR 全体は
render_full_image()で 1 枚の画像にして GPU テクスチャにキャッシュ。texture_dirtyで必要なときだけ再生成 TextureOptions::NEARESTと整数倍スケールでドットを滲ませないScrollAreaのオフセットを状態に書き戻して、アドレス表示や矢印キーの自動スクロールに使う- 日本語表示には Noto Sans JP を埋め込んで登録。太字は別ファミリーとして登録
即時モードは「状態 → 描画」が一方通行なので、バンクビューのように「データが変わったらテクスチャを作り直す」という設計に自然に落ち着きます。保持モードでやりがちな「どのウィジェットを更新すべきか」を考えなくて良いのは楽ですね。
次回は中央のドットエディタです。クリックした位置からドットの座標を割り出して、CHR に書き込んで、アンドゥできるようにする、というエディタの心臓部の話になります。
ではではぁ。
またまたぁ。















