editor-gui: a thin native (egui) editor over the editor-core seam

A new demo binary crate that proves the EditorSession seam end to end. It renders
the session's resolved layout with epiphany-render-svg, rasterizes that SVG with
resvg into an egui texture, resolves clicks back to staff-space world coordinates
to select, and drives all six note-editing intents from a toolbar and keyboard
(delete, chromatic transpose, staff-step move, add chord note, insert-after). A
debug panel shows the selection and the last applied op, proving the op log is
usable. Intentionally narrow: no duration editing, empty-space insert, or undo UI.

The one piece of nontrivial logic — the screen<->world map a click depends on — is
a pure ViewMap that inverts render-svg's translate(-min_x, max_y) scale(1 -1)
transform, unit-tested for round-trip, the y-up corners, and the logical-vs-ceiled
size mapping (the texture is displayed at the SVG's logical size with a uv crop, so
the click plane is not stretched by the pixmap's rounded-up dimensions). rerender
updates view box, logical size, and texture together on success and clears the
texture on a rasterization failure, so the pixels never disagree with the click
plane.

eframe + resvg are the workspace's first heavy external deps. eframe is pinned to
0.29 for the classic App::update(ctx) + panel API (0.35 redesigned App around
ui()). The crate needs a newer toolchain than the workspace MSRV, so CI excludes
it from the pinned-1.77 workspace commands and checks it in a separate
current-stable editor-gui job; the rest of the workspace is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
This commit is contained in:
Levi Neuwirth 2026-06-29 11:47:25 -04:00
parent d16435f2a9
commit c56ea4845f
5 changed files with 4868 additions and 10 deletions

View File

@ -36,14 +36,17 @@ jobs:
- name: Formatting - name: Formatting
run: cargo fmt --all -- --check run: cargo fmt --all -- --check
# The demo GUI crate (eframe/resvg) needs a newer toolchain than the pinned
# MSRV, so it is excluded from these MSRV-pinned workspace commands and
# checked on current stable in the `editor-gui` job below.
- name: Clippy (all targets) - name: Clippy (all targets)
run: cargo clippy --workspace --all-targets -- -D warnings run: cargo clippy --workspace --exclude epiphany-editor-gui --all-targets -- -D warnings
- name: Test (workspace) - name: Test (workspace)
run: cargo test --workspace --all-targets run: cargo test --workspace --exclude epiphany-editor-gui --all-targets
- name: Doc tests - name: Doc tests
run: cargo test --workspace --doc run: cargo test --workspace --exclude epiphany-editor-gui --doc
# Deny rustdoc warnings (broken/private intra-doc links, etc.) across the # Deny rustdoc warnings (broken/private intra-doc links, etc.) across the
# whole workspace. `RUSTFLAGS` does not cover rustdoc, so `RUSTDOCFLAGS` is # whole workspace. `RUSTFLAGS` does not cover rustdoc, so `RUSTDOCFLAGS` is
@ -51,7 +54,37 @@ jobs:
- name: Doc warnings (workspace) - name: Doc warnings (workspace)
env: env:
RUSTDOCFLAGS: -D warnings RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps run: cargo doc --workspace --exclude epiphany-editor-gui --no-deps
# The demo GUI crate, on current stable (its eframe/resvg tree requires a newer
# toolchain than the workspace MSRV). Separate so a GUI build break is its own
# attributable signal, not a regression in the core crates.
editor-gui:
name: editor GUI (current stable)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust (current stable)
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Install GUI build dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxkbcommon-dev libssl-dev
- name: Cache cargo
uses: Swatinem/rust-cache@v2
- name: Clippy (GUI)
run: cargo clippy -p epiphany-editor-gui --all-targets -- -D warnings
- name: Test (GUI)
run: cargo test -p epiphany-editor-gui
conformance: conformance:
name: conformance suite (testkit) name: conformance suite (testkit)

4339
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ members = [
"crates/epiphany-engrave", "crates/epiphany-engrave",
"crates/epiphany-render-svg", "crates/epiphany-render-svg",
"crates/epiphany-editor-core", "crates/epiphany-editor-core",
"crates/epiphany-editor-gui",
"crates/epiphany-testkit", "crates/epiphany-testkit",
] ]

View File

@ -0,0 +1,31 @@
[package]
name = "epiphany-editor-gui"
version = "0.0.0"
edition.workspace = true
authors.workspace = true
repository.workspace = true
description = "A thin native (egui) GUI shell over epiphany-editor-core: renders the score with epiphany-render-svg (rasterized by resvg), click to select, and a toolbar/keys for the note-editing intents. A vertical slice proving the editor seam — no duration editing, empty-space insert, or undo UI yet."
# A demo binary, not a library other crates build on, so it carries the heavy GUI
# dependency tree (eframe/winit/wgpu, resvg/usvg/tiny-skia) the rest of the
# workspace deliberately avoids. It opts out of the workspace MSRV claim because
# those crates require a newer toolchain than the core crates pin.
[dependencies]
# The headless editor this GUI drives.
epiphany-editor-core.workspace = true
# The real constraint solver (so the GUI shows a genuinely engraved score).
epiphany-engrave.workspace = true
# SVG rendering of the resolved layout, and the layout/hit-test geometry types.
epiphany-render-svg.workspace = true
epiphany-layout-ir.workspace = true
# Operation payload types, to label the last applied op in the debug panel.
epiphany-ops.workspace = true
# A clean score fixture to open.
epiphany-testkit.workspace = true
# Native GUI shell (window, event loop, immediate-mode UI). Pinned to the classic
# `App::update(ctx, frame)` + panel API (egui 0.35 redesigned `App` around `ui()`).
eframe = "0.29"
# Rasterizes the rendered SVG to a pixmap for display (bundles usvg + tiny-skia).
resvg = "0.45"

View File

@ -0,0 +1,466 @@
#![forbid(unsafe_code)]
//! # epiphany-editor-gui
//!
//! A **thin native GUI shell** over [`epiphany_editor_core::EditorSession`]: the
//! smallest real surface that proves the editor seam end to end. It renders the
//! session's resolved layout with `epiphany-render-svg`, rasterizes that SVG with
//! `resvg` into an `egui` texture, resolves clicks back to world coordinates to
//! select, and drives the note-editing intents from a toolbar and keyboard.
//!
//! Intentionally narrow (per the GUI vertical-slice plan): no duration editing, no
//! empty-space insertion, and no undo UI — the debug panel just shows the selection
//! and the last applied op, enough to confirm the op log is usable. The GUI is the
//! thing meant to surface the next real core gaps.
//!
//! It is a demo binary; there is no headless way to assert its rendering here, so the
//! one piece of nontrivial logic — the screen↔world coordinate map a click depends on
//! — is a pure [`ViewMap`] with a round-trip unit test.
use eframe::egui;
use epiphany_editor_core::{EditOutcome, EditorError, EditorSession};
use epiphany_engrave::Engraver;
use epiphany_layout_ir::{BoundingBox, HitShape, Point};
use epiphany_ops::{OperationKind, OperationPayload};
use epiphany_render_svg::{render, RenderOptions};
fn main() -> eframe::Result<()> {
let options = eframe::NativeOptions::default();
eframe::run_native(
"Epiphany editor (demo)",
options,
Box::new(|_cc| Ok(Box::new(EditorApp::new()))),
)
}
/// Maps between staff-space **world** coordinates (the hit-test map's space) and
/// on-screen points, given where the rendered image is currently drawn.
///
/// `epiphany-render-svg` lays the score out in a viewBox of `view_box = [min_x,
/// min_y, width, height]` staff spaces and draws it under a single
/// `translate(-min_x, max_y) scale(1 -1)` group (world is y-up, screen is y-down),
/// where `max_y = min_y + height`. Displaying that image in `rect` therefore makes
/// the world↔screen transform a uniform scale + flip, which this inverts.
struct ViewMap {
min_x: f32,
max_y: f32,
vb_width: f32,
rect: egui::Rect,
}
impl ViewMap {
fn new(view_box: [f32; 4], rect: egui::Rect) -> Self {
ViewMap {
min_x: view_box[0],
max_y: view_box[1] + view_box[3],
vb_width: view_box[2],
rect,
}
}
/// On-screen points per staff space, derived from the displayed rect (so it is
/// correct at any zoom/fit). Uniform: the image keeps the layout's aspect ratio.
fn scale(&self) -> f32 {
if self.vb_width > 0.0 {
self.rect.width() / self.vb_width
} else {
1.0
}
}
fn screen_to_world(&self, p: egui::Pos2) -> Point {
let s = self.scale();
Point::new(
self.min_x + (p.x - self.rect.min.x) / s,
self.max_y - (p.y - self.rect.min.y) / s,
)
}
fn world_to_screen(&self, x: f32, y: f32) -> egui::Pos2 {
let s = self.scale();
egui::pos2(
self.rect.min.x + (x - self.min_x) * s,
self.rect.min.y + (self.max_y - y) * s,
)
}
}
/// The screen rectangle covering a hit shape, for the selection highlight.
fn shape_rect(shape: &HitShape, vm: &ViewMap) -> egui::Rect {
match shape {
HitShape::Box(BoundingBox {
left,
bottom,
right,
top,
}) => egui::Rect::from_two_pos(
vm.world_to_screen(left.0, top.0),
vm.world_to_screen(right.0, bottom.0),
),
HitShape::Segment { from, to, .. } => egui::Rect::from_two_pos(
vm.world_to_screen(from.x.0, from.y.0),
vm.world_to_screen(to.x.0, to.y.0),
),
}
}
/// A short label for the last applied op, for the debug panel.
fn payload_label(payload: &OperationPayload) -> &'static str {
match payload {
OperationPayload::Primitive(kind) => match kind {
OperationKind::Transpose(_) => "Transpose",
OperationKind::ModifyIdentifiedPitch(_) => "ModifyIdentifiedPitch",
OperationKind::DeleteIdentifiedPitch(_) => "DeleteIdentifiedPitch",
OperationKind::DeleteEvent(_) => "DeleteEvent",
OperationKind::InsertIdentifiedPitch(_) => "InsertIdentifiedPitch",
OperationKind::InsertEvent(_) => "InsertEvent",
OperationKind::RespellPitch(_) => "RespellPitch",
OperationKind::DeclareTransaction(_) => "DeclareTransaction",
_ => "primitive",
},
OperationPayload::ResolveConflict(_) => "ResolveConflict",
OperationPayload::UndoTransaction(_) => "UndoTransaction",
}
}
/// Rasterizes a rendered SVG string to an `egui` image, returning the image and the
/// SVG's **logical** (sub-pixel, pre-`ceil`) size. The pixmap dimensions are the
/// logical size rounded up to whole pixels; the logical size is what the image must
/// be *displayed* at so the click plane maps back to the layout exactly (displaying
/// the rounded-up pixmap size would stretch the mapping past the content). The score
/// is drawn over an opaque white background so the pixmap's premultiplied alpha is
/// fully opaque, matching `from_rgba_unmultiplied`.
fn rasterize(svg: &str) -> Option<(egui::ColorImage, egui::Vec2)> {
let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).ok()?;
let size = tree.size();
let logical = egui::vec2(size.width(), size.height());
let width = size.width().ceil().max(1.0) as u32;
let height = size.height().ceil().max(1.0) as u32;
let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height)?;
pixmap.fill(resvg::tiny_skia::Color::WHITE);
resvg::render(
&tree,
resvg::tiny_skia::Transform::identity(),
&mut pixmap.as_mut(),
);
let image =
egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], pixmap.data());
Some((image, logical))
}
struct EditorApp {
session: EditorSession,
texture: Option<egui::TextureHandle>,
/// `[min_x, min_y, width, height]` of the last render, in staff spaces.
view_box: [f32; 4],
/// The logical (sub-pixel) on-screen size of the last render, in points — what
/// the texture is displayed at (its pixmap is this rounded up). Kept in step with
/// `texture` / `view_box` so the click plane matches the displayed content.
logical_size: egui::Vec2,
px_per_staff_space: f32,
needs_render: bool,
status: String,
}
impl EditorApp {
fn new() -> Self {
let score = epiphany_testkit::fixtures::ten_measure_single_staff(0);
let session = EditorSession::open(score, Box::new(Engraver))
.expect("the ten-measure fixture renders under the real engraver");
EditorApp {
session,
texture: None,
view_box: [0.0, 0.0, 1.0, 1.0],
logical_size: egui::vec2(1.0, 1.0),
px_per_staff_space: 12.0,
needs_render: true,
status: "opened ten_measure_single_staff".to_string(),
}
}
/// Runs an intent, recording its outcome (or error) in the status line and
/// scheduling a re-render.
fn run(
&mut self,
name: &str,
intent: impl FnOnce(&mut EditorSession) -> Result<EditOutcome, EditorError>,
) {
self.status = match intent(&mut self.session) {
Ok(outcome) => format!(
"{name}: ok (changed={}, selection kept={})",
outcome.graph_changed, outcome.selection_preserved
),
Err(err) => format!("{name}: {err}"),
};
self.needs_render = true;
}
/// Re-renders the session's resolved layout to a texture. The view box, logical
/// size, and texture are updated together (only on a successful rasterization), so
/// the displayed pixels never disagree with the click plane; on failure the score
/// view is cleared and the failure is surfaced rather than left stale.
fn rerender(&mut self, ctx: &egui::Context) {
let options = RenderOptions {
px_per_staff_space: self.px_per_staff_space,
..Default::default()
};
let output = render(self.session.resolved(), &options);
match rasterize(&output.svg) {
Some((image, logical)) => {
self.view_box = output.stats.view_box;
self.logical_size = logical;
self.texture = Some(ctx.load_texture("score", image, egui::TextureOptions::LINEAR));
}
None => {
self.texture = None;
self.status = "render failed: could not rasterize the score".to_string();
}
}
}
fn toolbar(&mut self, ui: &mut egui::Ui) {
ui.horizontal_wrapped(|ui| {
if ui.button("Delete").clicked() {
self.run("delete", |s| s.delete_selection());
}
if ui.button("Transpose ♯").clicked() {
self.run("transpose +1", |s| s.transpose_selection(1));
}
if ui.button("Transpose ♭").clicked() {
self.run("transpose -1", |s| s.transpose_selection(-1));
}
if ui.button("Move ↑").clicked() {
self.run("move up", |s| s.move_selection_staff_step(1));
}
if ui.button("Move ↓").clicked() {
self.run("move down", |s| s.move_selection_staff_step(-1));
}
if ui.button("Add chord note").clicked() {
self.run("add chord note", |s| s.add_note_to_selection());
}
if ui.button("Insert after").clicked() {
self.run("insert after", |s| s.insert_note_after_selection());
}
ui.separator();
if ui
.add(egui::Slider::new(&mut self.px_per_staff_space, 6.0..=28.0).text("zoom"))
.changed()
{
self.needs_render = true;
}
});
}
fn debug_panel(&self, ui: &mut egui::Ui) {
ui.heading("State");
ui.label(format!("status: {}", self.status));
ui.separator();
match self.session.selection() {
Some(sel) => {
ui.label(format!("selection source: {:?}", sel.source));
ui.label(format!("layout id: {:?}", sel.layout_object));
}
None => {
ui.label("selection: none");
}
}
ui.separator();
ui.label(format!(
"ops applied: {}",
self.session.applied_operations().len()
));
match self.session.last_applied() {
Some(env) => {
ui.label(format!("last op: {}", payload_label(&env.payload)));
ui.label(format!("op id: {:?}", env.id));
}
None => {
ui.label("last op: none");
}
}
ui.separator();
ui.label(
"Click a notehead to select.\n\
Keys: Del delete · / staff-step move · +/ transpose · A add chord · I insert after",
);
}
fn handle_keys(&mut self, ctx: &egui::Context) {
let k = ctx.input(|i| Keys {
delete: i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace),
move_up: i.key_pressed(egui::Key::ArrowUp),
move_down: i.key_pressed(egui::Key::ArrowDown),
sharp: i.key_pressed(egui::Key::Plus) || i.key_pressed(egui::Key::Equals),
flat: i.key_pressed(egui::Key::Minus),
add: i.key_pressed(egui::Key::A),
insert: i.key_pressed(egui::Key::I),
});
if k.delete {
self.run("delete", |s| s.delete_selection());
}
if k.move_up {
self.run("move up", |s| s.move_selection_staff_step(1));
}
if k.move_down {
self.run("move down", |s| s.move_selection_staff_step(-1));
}
if k.sharp {
self.run("transpose +1", |s| s.transpose_selection(1));
}
if k.flat {
self.run("transpose -1", |s| s.transpose_selection(-1));
}
if k.add {
self.run("add chord note", |s| s.add_note_to_selection());
}
if k.insert {
self.run("insert after", |s| s.insert_note_after_selection());
}
}
fn score_view(&mut self, ui: &mut egui::Ui) {
let Some(texture) = self.texture.clone() else {
return;
};
// Display at the logical size (not the rounded-up pixmap size), cropping the
// texture's uv to the logical content — so the click plane maps screen points
// back to the layout exactly, with no sub-pixel stretch from the `ceil`.
let pixmap = texture.size_vec2();
let logical = self.logical_size;
let (rect, response) = ui.allocate_exact_size(logical, egui::Sense::click());
let uv = egui::Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(logical.x / pixmap.x, logical.y / pixmap.y),
);
ui.painter()
.image(texture.id(), rect, uv, egui::Color32::WHITE);
if response.clicked() {
if let Some(pos) = response.interact_pointer_pos() {
let world = ViewMap::new(self.view_box, rect).screen_to_world(pos);
self.status = match self.session.click(world) {
Some(_) => "selected".to_string(),
None => "cleared selection".to_string(),
};
}
}
if let Some(sel) = self.session.selection() {
if let Some(region) = self
.session
.hit_test()
.regions
.iter()
.find(|r| r.layout_object == sel.layout_object)
{
let vm = ViewMap::new(self.view_box, rect);
ui.painter().rect_stroke(
shape_rect(&region.shape, &vm),
0.0,
egui::Stroke::new(2.0, egui::Color32::from_rgb(0, 120, 215)),
);
}
}
}
}
/// Keyboard edges read once per frame (so a held key fires one edit).
struct Keys {
delete: bool,
move_up: bool,
move_down: bool,
sharp: bool,
flat: bool,
add: bool,
insert: bool,
}
impl eframe::App for EditorApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.handle_keys(ctx);
egui::TopBottomPanel::top("toolbar").show(ctx, |ui| self.toolbar(ui));
egui::SidePanel::right("debug")
.default_width(280.0)
.show(ctx, |ui| self.debug_panel(ui));
if self.needs_render {
self.rerender(ctx);
self.needs_render = false;
}
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::both().show(ui, |ui| self.score_view(ui));
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn view_map_round_trips_screen_and_world() {
// A viewBox offset in both axes (min_y negative), shown in an off-origin rect.
let view_box = [3.0_f32, -5.0, 40.0, 12.0];
let rect = egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(400.0, 120.0));
let vm = ViewMap::new(view_box, rect);
for &(sx, sy) in &[(100.0, 50.0), (500.0, 170.0), (300.0, 110.0), (250.0, 75.0)] {
let world = vm.screen_to_world(egui::pos2(sx, sy));
let back = vm.world_to_screen(world.x.0, world.y.0);
assert!(
(back.x - sx).abs() < 1e-3,
"x round-trips: {} vs {sx}",
back.x
);
assert!(
(back.y - sy).abs() < 1e-3,
"y round-trips: {} vs {sy}",
back.y
);
}
}
#[test]
fn click_plane_uses_the_logical_not_ceiled_size() {
// 82.4564 staff spaces at 12 px/space is 989.4768 logical px, which a pixmap
// rounds up to 990. The click plane must map the *logical* rect to the
// viewBox, so the scale is exactly px-per-staff-space — using the rounded-up
// pixmap size instead would stretch it.
let view_box = [0.0_f32, 0.0, 82.4564, 30.0];
let pps = 12.0_f32;
let logical = egui::vec2(view_box[2] * pps, view_box[3] * pps);
let logical_rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), logical);
let correct = ViewMap::new(view_box, logical_rect);
assert!(
(correct.scale() - pps).abs() < 1e-3,
"logical rect gives scale == px/space, got {}",
correct.scale()
);
// The rounded-up pixmap size must NOT be used as the display rect — a
// regression guard that it would give a different (wrong) scale.
let ceiled = egui::vec2(logical.x.ceil(), logical.y.ceil());
let ceiled_rect = egui::Rect::from_min_size(egui::pos2(0.0, 0.0), ceiled);
assert!(
(ViewMap::new(view_box, ceiled_rect).scale() - pps).abs() > 1e-3,
"the rounded-up size would stretch the click plane"
);
}
#[test]
fn view_map_corners_respect_the_y_up_world() {
let view_box = [3.0_f32, -5.0, 40.0, 12.0]; // max_y = -5 + 12 = 7
let rect = egui::Rect::from_min_size(egui::pos2(100.0, 50.0), egui::vec2(400.0, 120.0));
let vm = ViewMap::new(view_box, rect);
// Screen top-left is world (min_x, max_y); screen bottom-left is (min_x, min_y).
let top_left = vm.screen_to_world(rect.min);
assert!((top_left.x.0 - 3.0).abs() < 1e-3);
assert!((top_left.y.0 - 7.0).abs() < 1e-3);
let bottom_left = vm.screen_to_world(egui::pos2(rect.min.x, rect.max.y));
assert!((bottom_left.y.0 - (-5.0)).abs() < 1e-3);
}
}