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:
parent
d16435f2a9
commit
c56ea4845f
|
|
@ -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)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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(®ion.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue