diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e584b511 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +# The gate AGENTS.md already documents — format, lint, test, doc — enforced. +# Until now nothing ran `cargo clippy -D warnings` or `cargo test --workspace` +# in CI, so lint regressions landed on `main` unnoticed (14 of them in the +# cockpit alone). The wasm render is checked separately by cockpit-web-test. +# +# `fuzz/` is a nightly, non-member crate (ADR-0010), so `--workspace` never +# reaches it; the fuzz gate stays its own concern. +name: ci +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + +jobs: + check: + runs-on: ubuntu-latest + steps: + # persist-credentials: false — later steps compile and run PR-authored + # code (build scripts, tests), so don't leave the token in .git/config + # for them to read (zizmor: artipacked). + - uses: actions/checkout@v4 + with: + persist-credentials: false + + # rust-toolchain.toml pins the channel and pulls in rustfmt + clippy. + - name: Show toolchain + run: rustc --version && cargo clippy --version && cargo fmt --version + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ci-${{ hashFiles('Cargo.lock', '**/Cargo.toml') }} + + # eframe needs a windowing/GL stack to *link* the cockpit's native target. + - name: Install native deps + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev \ + libxcb-xfixes0-dev libxkbcommon-dev libssl-dev + + - name: Format + run: cargo fmt --all --check + - name: Lint + run: cargo clippy --workspace --all-targets -- -D warnings + - name: Test + run: cargo test --workspace + # Not yet `-D warnings`: rustdoc flags five pre-existing findings in + # griff-core (an unresolved `AtomRest` link, public docs linking to + # private items). Deny once those are cleared. + - name: Doc + run: cargo doc --no-deps --workspace + + # The MSRV is a promise; build on it or it rots. It sat at 1.74 for a year + # while egui/eframe 0.34 already demanded 1.92 — nobody on 1.74 could have + # built the cockpit. + msrv: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Read the MSRV from the workspace manifest + id: msrv + run: | + v=$(grep -m1 -E '^rust-version' Cargo.toml | grep -oE '[0-9]+\.[0-9]+(\.[0-9]+)?') + echo "version=$v" >> "$GITHUB_OUTPUT" + echo "workspace MSRV: $v" + - name: Install the MSRV toolchain + run: rustup toolchain install ${{ steps.msrv.outputs.version }} --profile minimal + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: msrv-${{ steps.msrv.outputs.version }}-${{ hashFiles('Cargo.lock') }} + + - name: Install native deps + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libxcb-render0-dev libxcb-shape0-dev \ + libxcb-xfixes0-dev libxkbcommon-dev libssl-dev + + - name: Build on the MSRV + run: cargo +${{ steps.msrv.outputs.version }} check --workspace --all-targets diff --git a/Cargo.toml b/Cargo.toml index 24cd15ce..748499d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ resolver = "2" [workspace.package] version = "0.1.0" edition = "2021" -rust-version = "1.74" +rust-version = "1.92" license = "MIT" repository = "https://github.com/physshell/griff" homepage = "https://github.com/physshell/griff" diff --git a/cockpit/src/generation.rs b/cockpit/src/generation.rs index dec6083f..8c4299c5 100644 --- a/cockpit/src/generation.rs +++ b/cockpit/src/generation.rs @@ -9,6 +9,13 @@ //! I/O the native app owns (the web app reads the same records out of OPFS), and //! the provenance a kept candidate is stamped with. +#[cfg(not(target_arch = "wasm32"))] +use std::path::Path; + +#[cfg(not(target_arch = "wasm32"))] +use griff_core::corpus::ChunkMeta; +#[cfg(not(target_arch = "wasm32"))] +use griff_core::generation_input::CorpusMaterial; use griff_core::generation_input::GenerationAsk; use griff_ui_core::generate::CandidateSet; @@ -53,7 +60,7 @@ impl GeneratePanel { /// A panel with the CLI's defaults (`griff generate`: seed 0, 8 bars, /// 2 variants per strategy, gesture on). #[must_use] - pub fn new() -> Self { + pub const fn new() -> Self { Self { open: false, sources: Vec::new(), @@ -92,14 +99,16 @@ impl GeneratePanel { #[derive(Debug)] pub struct LoadedCorpus { /// The rhythm/novelty/gesture material. - pub material: griff_core::generation_input::CorpusMaterial, + pub material: CorpusMaterial, /// The distinct source tabs the records point at, by first-seen order. pub sources: Vec, } -/// Reads a corpus *directory* — the native app's I/O half, mirroring what the -/// CLI's `load_corpus_material` does and what the web app does over OPFS. Every -/// musical decision (slicing, rhythm extraction, gesture aggregation) is core's. +/// Reads a corpus *directory* — the native app's I/O half. +/// +/// Mirrors what the CLI's `load_corpus_material` does and what the web app does +/// over OPFS. Every musical decision (slicing, rhythm extraction, gesture +/// aggregation) is core's. /// /// Records are visited in sorted order, so the rhythm-template palette is /// deterministic. A record whose source is missing, unreadable, unimportable, or @@ -108,7 +117,7 @@ pub struct LoadedCorpus { /// # Errors /// A message when `dir` cannot be read. #[cfg(not(target_arch = "wasm32"))] -pub fn load_corpus_dir(dir: &std::path::Path) -> Result { +pub fn load_corpus_dir(dir: &Path) -> Result { use std::fs; use griff_core::generation_input::{corpus_material, prepare_chunk}; @@ -159,14 +168,10 @@ pub fn load_corpus_dir(dir: &std::path::Path) -> Result { /// Reads one record and the bytes of the tab it names. `None` when either is /// missing or unparseable. #[cfg(not(target_arch = "wasm32"))] -fn read_record( - dir: &std::path::Path, - record: &str, -) -> Option<(griff_core::corpus::ChunkMeta, Vec)> { +fn read_record(dir: &Path, record: &str) -> Option<(ChunkMeta, Vec)> { use std::fs; - let meta: griff_core::corpus::ChunkMeta = - serde_json::from_str(&fs::read_to_string(dir.join(record)).ok()?).ok()?; + let meta: ChunkMeta = serde_json::from_str(&fs::read_to_string(dir.join(record)).ok()?).ok()?; let bytes = fs::read(dir.join(&meta.source.filename)).ok()?; Some((meta, bytes)) } diff --git a/cockpit/src/lib.rs b/cockpit/src/lib.rs index 77fd9d0f..c3f0ca86 100644 --- a/cockpit/src/lib.rs +++ b/cockpit/src/lib.rs @@ -26,6 +26,9 @@ clippy::suboptimal_flops )] +#[cfg(not(target_arch = "wasm32"))] +use std::path::{Path, PathBuf}; + use eframe::egui::{self, Align2, Color32, CornerRadius, FontId, Key, Rect}; use griff_core::classify::BarClass; @@ -59,7 +62,10 @@ const BOUNDARY: Color32 = Color32::from_rgb(0xff, 0x5d, 0x6c); const PLAYHEAD: Color32 = Color32::from_rgb(0xff, 0xcf, 0x4d); const PANEL: Color32 = Color32::from_rgb(0x24, 0x24, 0x27); const LABEL_DIM: Color32 = Color32::from_rgb(0x9a, 0x9a, 0xa2); -const LABEL_FAINT: Color32 = Color32::from_rgb(0x6e, 0x6e, 0x76); +/// Ink for pale fills — the design mock's `--bg` family, not pure black. +const INK: Color32 = Color32::from_rgb(0x11, 0x11, 0x14); +/// How far the selected section's fill is lifted toward white. +const SELECTED_LIFT: f32 = 0.35; /// Colour for a bar classification (section marks and the section band). const fn class_color(class: BarClass) -> Color32 { @@ -72,6 +78,33 @@ const fn class_color(class: BarClass) -> Color32 { } } +/// Lifts a colour toward white by `t` — the band's de-emphasis runs this way +/// round, not by dimming. The class hues are dark enough on this surface +/// (Breakdown clears the 3:1 floor for meaningful graphics by 0.09) that dimming +/// the *unselected* sections, as this renderer used to, pushed them under the +/// floor and left the selection darker — quieter — than its neighbours. Lifting +/// the selection instead keeps every section legible and makes the active one +/// the brightest thing in the band. +fn lift(c: Color32, t: f32) -> Color32 { + let toward_white = |v: u8| f32::from(v) + (255.0 - f32::from(v)) * t; + Color32::from_rgb( + toward_white(c.r()) as u8, + toward_white(c.g()) as u8, + toward_white(c.b()) as u8, + ) +} + +/// The ink a section's class label is drawn in, against its own fill: white on +/// the deep hues, [`INK`] on the bright ones. Every pairing clears 4.5:1, so the +/// label — not the colour — is what carries the classification (WCAG 1.4.1: the +/// Breakdown/Clean red-green pair is invisible to a deuteranope). +const fn on_class_color(class: BarClass) -> Color32 { + match class { + BarClass::Riff | BarClass::Breakdown | BarClass::Unknown => Color32::WHITE, + BarClass::Solo | BarClass::Clean => INK, + } +} + /// Note-lane colour, cycled by lane index (six lanes, then it wraps). const fn lane_color(lane: u16) -> Color32 { match lane % 6 { @@ -99,9 +132,9 @@ fn role_color(role: CellRole, shade: bool) -> Option { CellRole::BandFill { class, selected } => { let base = class_color(class); Some(if selected { - base + lift(base, SELECTED_LIFT) } else { - base.gamma_multiply(0.55) + base }) } CellRole::BandHeader => Some(PANEL), @@ -110,10 +143,20 @@ fn role_color(role: CellRole, shade: bool) -> Option { /// The glyph colour for a textual cell, or `None` when the cell draws as a /// solid block (no glyph). +/// +/// The band is textual: `scene::resolve_band` centres each section's class name +/// in its span, and dropping that glyph would leave the cockpit encoding the +/// class by colour alone — and showing less than the `ratatui` preview does off +/// the same `Scene` (ADR-0016). const fn glyph_color(role: CellRole) -> Option { match role { - CellRole::PitchLabel => Some(LABEL_DIM), - CellRole::BandHeader => Some(LABEL_FAINT), + // The header shared the gutter's dim label colour once the old faint one + // (3.06:1 on the panel) was dropped for reading under the 4.5:1 floor. + CellRole::PitchLabel | CellRole::BandHeader => Some(LABEL_DIM), + // The selected fill is the lifted, pale one, so it takes ink either way. + CellRole::BandFill { class, selected } => { + Some(if selected { INK } else { on_class_color(class) }) + } _ => None, } } @@ -553,7 +596,7 @@ pub struct CockpitApp { material: Option, /// Where a kept candidate is written (native only). #[cfg(not(target_arch = "wasm32"))] - out_dir: std::path::PathBuf, + out_dir: PathBuf, } /// A single-track view of `score`: just `track`, so the roll shows one part @@ -609,7 +652,7 @@ impl CockpitApp { gen_panel: GeneratePanel::new(), material: None, #[cfg(not(target_arch = "wasm32"))] - out_dir: std::path::PathBuf::from("keeps"), + out_dir: PathBuf::from("keeps"), } } @@ -706,21 +749,24 @@ impl CockpitApp { /// Where kept candidates are written. #[cfg(not(target_arch = "wasm32"))] - pub fn set_out_dir(&mut self, dir: std::path::PathBuf) { + pub fn set_out_dir(&mut self, dir: PathBuf) { self.out_dir = dir; } /// The score a pass seeds from: the picked corpus tab, or — when none is /// picked (or the pick is stale) — the displayed score. fn generation_source(&self) -> Result { - match self.gen_panel.source_tab() { - Some(tab) => import_score_auto(&tab.bytes) - .map_err(|err| format!("cannot import {}: {err}", tab.name)), - None => self - .score - .clone() - .ok_or_else(|| "no score loaded".to_owned()), - } + self.gen_panel.source_tab().map_or_else( + || { + self.score + .clone() + .ok_or_else(|| "no score loaded".to_owned()) + }, + |tab| { + import_score_auto(&tab.bytes) + .map_err(|err| format!("cannot import {}: {err}", tab.name)) + }, + ) } /// Runs the panel's ask through the shared compiler and shows the winner. @@ -829,7 +875,7 @@ impl CockpitApp { fn open_keep(&mut self, i: usize) { let outcome = self .write_keep(i) - .and_then(|path| open_in_default_app(std::path::Path::new(&path)).map(|()| path)); + .and_then(|path| open_in_default_app(Path::new(&path)).map(|()| path)); self.gen_panel.status = Some(match outcome { Ok(path) => format!("opened {path}"), Err(err) => format!("open failed: {err}"), @@ -892,72 +938,7 @@ impl CockpitApp { } }); - let Some(set) = self.gen_panel.set.as_ref() else { - ui.separator(); - ui.weak(match self.material { - Some(_) => "a corpus is loaded — generate to rank a candidate set", - None => "no corpus: the pass will seed from the displayed score alone", - }); - return; - }; - - ui.separator(); - let gesture = set.summary.gesture.as_ref().map_or_else( - || "off".to_owned(), - |(n, rest)| format!("{n} notes / {rest}"), - ); - ui.weak(format!( - "{} templates · {} references · gesture {} · {}-tone scale{}", - set.summary.templates, - set.summary.references, - gesture, - set.summary.scale_tones, - if set.summary.skipped.is_empty() { - String::new() - } else { - format!(" · {} records skipped", set.summary.skipped.len()) - }, - )); - ui.separator(); - - egui::ScrollArea::vertical().show(ui, |ui| { - for (i, row) in set.rows.iter().enumerate() { - let selected = self.gen_panel.selected == Some(i); - let label = format!( - "{:>3}. {:<26} {:.3} {} notes", - row.rank, row.strategy, row.aggregate, row.note_count, - ); - let hover = row - .axes - .iter() - .map(|(name, value)| format!("{name} {value:.2}")) - .collect::>() - .join("\n"); - if ui - .selectable_label(selected, egui::RichText::new(label).monospace()) - .on_hover_text(hover) - .clicked() - { - show = Some(i); - } - } - }); - - if let Some(i) = self.gen_panel.selected { - ui.separator(); - ui.horizontal(|ui| { - if ui.button("⤓ keep .mid").clicked() { - keep = Some(i); - } - if ui - .button("🔊 open") - .on_hover_text("write it and hand it to your .mid app") - .clicked() - { - open = Some(i); - } - }); - } + self.generate_candidates(ui, &mut show, &mut keep, &mut open); }); if run { @@ -981,6 +962,85 @@ impl CockpitApp { } } + /// The Generate panel's lower half: the set's provenance line, the ranked + /// rows, and the keep actions for the selected one. Reports what the user + /// asked for through `show` / `keep` / `open`, so the window applies every + /// action after the panel closes its borrow of the panel state. + fn generate_candidates( + &self, + ui: &mut egui::Ui, + show: &mut Option, + keep: &mut Option, + open: &mut Option, + ) { + let Some(set) = self.gen_panel.set.as_ref() else { + ui.separator(); + ui.weak(match self.material { + Some(_) => "a corpus is loaded — generate to rank a candidate set", + None => "no corpus: the pass will seed from the displayed score alone", + }); + return; + }; + + ui.separator(); + let gesture = set.summary.gesture.as_ref().map_or_else( + || "off".to_owned(), + |(n, rest)| format!("{n} notes / {rest}"), + ); + ui.weak(format!( + "{} templates · {} references · gesture {} · {}-tone scale{}", + set.summary.templates, + set.summary.references, + gesture, + set.summary.scale_tones, + if set.summary.skipped.is_empty() { + String::new() + } else { + format!(" · {} records skipped", set.summary.skipped.len()) + }, + )); + ui.separator(); + + egui::ScrollArea::vertical().show(ui, |ui| { + for (i, row) in set.rows.iter().enumerate() { + let selected = self.gen_panel.selected == Some(i); + let label = format!( + "{:>3}. {:<26} {:.3} {} notes", + row.rank, row.strategy, row.aggregate, row.note_count, + ); + let hover = row + .axes + .iter() + .map(|(name, value)| format!("{name} {value:.2}")) + .collect::>() + .join("\n"); + if ui + .selectable_label(selected, egui::RichText::new(label).monospace()) + .on_hover_text(hover) + .clicked() + { + *show = Some(i); + } + } + }); + + if let Some(i) = self.gen_panel.selected { + ui.separator(); + ui.horizontal(|ui| { + if ui.button("⤓ keep .mid").clicked() { + *keep = Some(i); + } + if ui + .button("🔊 open") + .on_hover_text("write it and hand it to your .mid app") + .clicked() + { + *open = Some(i); + } + }); + } + } + /// Captures the focused track of the loaded score as a `chunk.json` string /// (ADR-0026), through the shared [`griff_ui_core::capture::build_chunk`] — /// byte-compatible with what `griff manifest` reads. @@ -1406,7 +1466,7 @@ impl eframe::App for CockpitApp { /// # Errors /// A message when the handler cannot be spawned. #[cfg(not(target_arch = "wasm32"))] -fn open_in_default_app(path: &std::path::Path) -> Result<(), String> { +fn open_in_default_app(path: &Path) -> Result<(), String> { use std::process::Command; let mut cmd = if cfg!(target_os = "windows") { @@ -1650,6 +1710,8 @@ mod tests { use super::*; use eframe::egui; + use eframe::egui::epaint::ClippedShape; + use eframe::egui::Shape; #[test] fn every_bar_class_has_a_distinct_colour() { @@ -1713,6 +1775,162 @@ mod tests { assert_ne!(sel, unsel); } + /// Every bar classification, in `BarClass` declaration order. + const CLASSES: [BarClass; 5] = [ + BarClass::Riff, + BarClass::Breakdown, + BarClass::Solo, + BarClass::Clean, + BarClass::Unknown, + ]; + + /// The surface the scene is painted onto — `CentralPanel` fills with it. + fn surface() -> Color32 { + egui::Visuals::dark().panel_fill + } + + /// Relative luminance of an opaque colour (WCAG 2.1 §1.4.3). + fn luminance(c: Color32) -> f64 { + let channel = |v: u8| { + let v = f64::from(v) / 255.0; + if v <= 0.03928 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }; + 0.2126 * channel(c.r()) + 0.7152 * channel(c.g()) + 0.0722 * channel(c.b()) + } + + /// The WCAG contrast ratio between two opaque colours, in `1.0..=21.0`. + fn contrast(a: Color32, b: Color32) -> f64 { + let (la, lb) = (luminance(a), luminance(b)); + let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) }; + (hi + 0.05) / (lo + 0.05) + } + + #[test] + fn the_section_band_labels_its_class_it_does_not_only_colour_it() { + // `scene::resolve_band` centres the class name in each section's span, + // and the ratatui preview draws it. A renderer that drops the glyph + // encodes the class by colour alone — which WCAG 1.4.1 forbids, and + // which Breakdown (red) against Clean (green) makes unreadable to a + // deuteranope — and silently diverges from the other frontend (ADR-0016). + for class in CLASSES { + for selected in [true, false] { + assert!( + glyph_color(CellRole::BandFill { class, selected }).is_some(), + "{class:?} (selected={selected}) paints a block with no label" + ); + } + } + } + + #[test] + fn the_band_class_label_is_legible_on_its_own_fill() { + for class in CLASSES { + for selected in [true, false] { + let role = CellRole::BandFill { class, selected }; + let fill = role_color(role, false).expect("the band fills"); + let label = glyph_color(role).expect("the band labels"); + let ratio = contrast(fill, label); + assert!( + ratio >= 4.5, + "{class:?} (selected={selected}) label at {ratio:.2}:1, \ + under the 4.5:1 text floor" + ); + } + } + } + + #[test] + fn an_unselected_band_section_keeps_its_class_visible() { + // Dimming the fill is how the band de-emphasises the sections the + // viewport has not selected; dimming it below the 3:1 floor for + // meaningful graphics erases the classification instead. + for class in CLASSES { + let fill = role_color( + CellRole::BandFill { + class, + selected: false, + }, + false, + ) + .expect("the band fills"); + let ratio = contrast(fill, surface()); + assert!( + ratio >= 3.0, + "unselected {class:?} at {ratio:.2}:1 against the surface" + ); + } + } + + #[test] + fn the_band_header_meets_the_text_contrast_floor() { + let fill = role_color(CellRole::BandHeader, false).expect("the header fills"); + let glyph = glyph_color(CellRole::BandHeader).expect("the header is text"); + let ratio = contrast(fill, glyph); + assert!( + ratio >= 4.5, + "the SEC header reads at {ratio:.2}:1, under the 4.5:1 text floor" + ); + } + + /// Every glyph the painter emitted in one frame, in paint order. + fn painted_glyphs(shapes: &[ClippedShape]) -> String { + fn walk(shape: &Shape, out: &mut String) { + match shape { + Shape::Text(text) => out.push_str(text.galley.text()), + Shape::Vec(shapes) => { + for s in shapes { + walk(s, out); + } + } + _ => {} + } + } + let mut out = String::new(); + for clipped in shapes { + walk(&clipped.shape, &mut out); + } + out + } + + #[test] + // egui 0.34 flags `Context::run` / `CentralPanel::show`; they still drive a + // CPU frame, which is exactly what this test needs (as the paint tests + // above do). + #[allow(deprecated)] + fn the_painted_band_spells_out_the_section_class() { + // The end of the path the unit tests only cover in pieces: resolve a + // real scene, run one CPU frame, and read back what the painter actually + // drew. A colour mapping that returns the right ink is worth nothing if + // the glyph never reaches a shape. + let mut app = demo_app(); + let ctx = egui::Context::default(); + let input = egui::RawInput { + screen_rect: Some(Rect::from_min_size( + egui::pos2(0.0, 0.0), + egui::vec2(1200.0, 600.0), + )), + ..Default::default() + }; + let output = ctx.run(input, |ctx| { + egui::CentralPanel::default().show(ctx, |ui| app.paint(ui)); + }); + + let painted = painted_glyphs(&output.shapes); + assert!( + painted.contains("SEC"), + "the band's gutter header never reached the painter: {painted:?}" + ); + let classes = ["Riff", "Breakdown", "Solo", "Clean", "Unknown"]; + assert!( + classes.iter().any(|class| painted.contains(class)), + "the band painted no class label at all: {painted:?}" + ); + } + #[test] fn all_mapped_keys_resolve_to_their_intent() { use Intent::{ diff --git a/cockpit/src/main.rs b/cockpit/src/main.rs index 9dc57438..cdd60240 100644 --- a/cockpit/src/main.rs +++ b/cockpit/src/main.rs @@ -10,6 +10,8 @@ //! session runs without touching the CLI. The browser (wasm) entry point is //! `griff_cockpit::web::start` (see `lib.rs`, Slice 2). +#[cfg(not(target_arch = "wasm32"))] +use std::path::PathBuf; #[cfg(not(target_arch = "wasm32"))] use std::process::ExitCode; @@ -19,41 +21,63 @@ const USAGE: &str = "usage: griff-cockpit [file.mid|.gp3|.gp4|.gp5|.gpx] \ a file, a corpus, or both — with only a corpus the cockpit \ opens on its first tab"; +/// The command line: a file to open, a corpus to rank against, a keep directory. #[cfg(not(target_arch = "wasm32"))] -fn main() -> ExitCode { - use std::{env, fs, path::PathBuf}; +struct Args { + /// The score to open on; `None` falls back to the corpus's first tab. + input: Option, + /// The curated corpus the Generate panel draws its material from. + corpus: Option, + /// Where kept candidates are written. + out: Option, +} - use griff_cockpit::generation::load_corpus_dir; - use griff_cockpit::CockpitApp; - use griff_core::import::import_score_auto; +/// Parses the three flags. `Err` carries the code to exit with — the usage was +/// asked for, or a flag came without its directory. +/// +/// A hand-rolled parse: the cockpit is a window, not a CLI, and a clap +/// dependency here would be the tail wagging the dog. +#[cfg(not(target_arch = "wasm32"))] +fn parse_args() -> Result { + use std::env; - // A three-flag hand-rolled parse: the cockpit is a window, not a CLI, and a - // clap dependency here would be the tail wagging the dog. let (mut input, mut corpus, mut out) = (None::, None::, None::); let mut args = env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { - "--corpus" => match args.next() { - Some(dir) => corpus = Some(PathBuf::from(dir)), - None => { - eprintln!("--corpus needs a directory\n{USAGE}"); - return ExitCode::FAILURE; - } - }, - "--out" => match args.next() { - Some(dir) => out = Some(PathBuf::from(dir)), - None => { - eprintln!("--out needs a directory\n{USAGE}"); - return ExitCode::FAILURE; + "--corpus" | "--out" => { + let Some(dir) = args.next() else { + eprintln!("{arg} needs a directory\n{USAGE}"); + return Err(ExitCode::FAILURE); + }; + if arg == "--corpus" { + corpus = Some(PathBuf::from(dir)); + } else { + out = Some(PathBuf::from(dir)); } - }, + } "-h" | "--help" => { println!("{USAGE}"); - return ExitCode::SUCCESS; + return Err(ExitCode::SUCCESS); } other => input = Some(other.to_owned()), } } + Ok(Args { input, corpus, out }) +} + +#[cfg(not(target_arch = "wasm32"))] +fn main() -> ExitCode { + use std::fs; + + use griff_cockpit::generation::load_corpus_dir; + use griff_cockpit::CockpitApp; + use griff_core::import::import_score_auto; + + let Args { input, corpus, out } = match parse_args() { + Ok(args) => args, + Err(code) => return code, + }; let loaded = match corpus.as_deref().map(load_corpus_dir).transpose() { Ok(loaded) => loaded, diff --git a/cockpit/web-test/helpers.js b/cockpit/web-test/helpers.js index 0f46c078..ec213f27 100644 --- a/cockpit/web-test/helpers.js +++ b/cockpit/web-test/helpers.js @@ -13,10 +13,16 @@ export const LAUNCH_ARGS = [ // Signature solid fills from cockpit/src/lib.rs (role -> colour). Flat fills // match near-exactly; anti-aliased edges are noise. +// +// The demo's first section classifies as Breakdown, and the band boots with +// section 0 selected — whose fill is *lifted* toward white, not the base red +// (the selection reads by lift, not by dimming the rest). So the red the boot +// frame actually paints is the lifted one; the tolerance also covers the +// theme's `--sec-breakdown-selected` token (#e0666f), one step away. export const SIGNATURE = { 'note lane-0 (orange)': [0xff, 0x7a, 0x45], 'Riff band (blue)': [0x16, 0x68, 0xdc], - 'Breakdown band (red)': [0xcf, 0x13, 0x22], + 'Breakdown band (red, selected/lifted)': [0xdf, 0x65, 0x6f], 'playhead (yellow)': [0xff, 0xcf, 0x4d], }; export const BG = [0x1b, 0x1b, 0x1f]; // index.html body background diff --git a/core/src/boundary.rs b/core/src/boundary.rs index cc88aa5a..f6e6d421 100644 --- a/core/src/boundary.rs +++ b/core/src/boundary.rs @@ -269,9 +269,8 @@ fn apply_min_gap(boundaries: Vec, min_gap: Ticks) -> Vec = None; for b in boundaries { - let accept = last_kept_tick.map_or(true, |last| { - b.start_tick.0.saturating_sub(last) >= min_gap.0 - }); + let accept = + last_kept_tick.is_none_or(|last| b.start_tick.0.saturating_sub(last) >= min_gap.0); if accept { last_kept_tick = Some(b.start_tick.0); kept.push(b); diff --git a/core/src/generate.rs b/core/src/generate.rs index 8dbaed3c..a67760fc 100644 --- a/core/src/generate.rs +++ b/core/src/generate.rs @@ -467,7 +467,7 @@ impl Xorshift64 { }) } - fn next_u64(&mut self) -> u64 { + const fn next_u64(&mut self) -> u64 { let mut x = self.0; x ^= x.wrapping_shl(13); x ^= x.wrapping_shr(7); diff --git a/core/src/generation_input.rs b/core/src/generation_input.rs index f56803ce..d40eb863 100644 --- a/core/src/generation_input.rs +++ b/core/src/generation_input.rs @@ -433,6 +433,6 @@ fn median(mut values: Vec) -> f64 { } else { let hi = values.get(mid).copied().unwrap_or(0.0); let lo = values.get(mid.saturating_sub(1)).copied().unwrap_or(0.0); - (lo + hi) / 2.0 + f64::midpoint(lo, hi) } } diff --git a/core/src/gesture.rs b/core/src/gesture.rs index a12f5c86..4e8977c6 100644 --- a/core/src/gesture.rs +++ b/core/src/gesture.rs @@ -299,7 +299,7 @@ impl GestureControl { /// callers wanting wall-to-wall writing skip the compiler entirely. #[must_use] #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // rounded, clamped ≥ 1 - pub fn from_stats(stats: &GestureStats) -> Self { + pub const fn from_stats(stats: &GestureStats) -> Self { let burst = if stats.mean_burst_notes.is_finite() { stats.mean_burst_notes.round().max(1.0) } else { diff --git a/core/src/pitch.rs b/core/src/pitch.rs index 8d09caa5..6f44883f 100644 --- a/core/src/pitch.rs +++ b/core/src/pitch.rs @@ -56,7 +56,7 @@ impl PitchClassSet { /// `true` when the set holds no classes. #[must_use] - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.classes.is_empty() } @@ -118,13 +118,13 @@ impl ScaleLadder { /// Number of rungs (always ≥ 1). #[must_use] - pub fn len(&self) -> usize { + pub const fn len(&self) -> usize { self.pitches.len() } /// Always `false` — the ladder is never empty; present for lint parity. #[must_use] - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.pitches.is_empty() } @@ -315,7 +315,7 @@ fn stddev(pitches: &[u8]) -> f64 { #[cfg(test)] mod tests { - #![allow(clippy::indexing_slicing)] + #![allow(clippy::expect_used, clippy::indexing_slicing)] use super::{PitchClassSet, PitchRange, ScaleLadder}; use crate::event::Pitch; diff --git a/core/src/score.rs b/core/src/score.rs index 21a1d742..6ee35f37 100644 --- a/core/src/score.rs +++ b/core/src/score.rs @@ -63,7 +63,7 @@ impl LossReport { } /// Returns `true` when no losses were recorded. - pub fn is_clean(&self) -> bool { + pub const fn is_clean(&self) -> bool { self.warnings.is_empty() } diff --git a/core/src/scoring.rs b/core/src/scoring.rs index 01db31f4..c3b1ff34 100644 --- a/core/src/scoring.rs +++ b/core/src/scoring.rs @@ -64,13 +64,13 @@ impl Axes { /// The number of axes. #[must_use] - pub fn len(&self) -> usize { + pub const fn len(&self) -> usize { self.0.len() } /// Whether the axis set is empty. #[must_use] - pub fn is_empty(&self) -> bool { + pub const fn is_empty(&self) -> bool { self.0.is_empty() } } diff --git a/core/src/structure.rs b/core/src/structure.rs index 3b6d0189..f3f636c3 100644 --- a/core/src/structure.rs +++ b/core/src/structure.rs @@ -441,7 +441,7 @@ fn loopability(notes: &[NoteRef], score: &Score) -> f64 { 1.0 - f64::from(seam_gap.min(bar_ticks)) / f64::from(bar_ticks) }; - (pitch_seam + timing_seam) / 2.0 + f64::midpoint(pitch_seam, timing_seam) } // ── ComplexityProfile: the per-axis complexity vector (ADR-0015, glossary §7) ─ diff --git a/core/tests/shuffle_window.rs b/core/tests/shuffle_window.rs index a0726f0c..a63bf9be 100644 --- a/core/tests/shuffle_window.rs +++ b/core/tests/shuffle_window.rs @@ -44,7 +44,7 @@ fn octave_window_spans_at_most_one_octave() { let ladder = ScaleLadder::build(&PitchRange::new(Pitch(28), Pitch(64)), &classes).expect("ok"); for selector in 0..64 { let w = ladder.octave_window(selector); - assert!(w.len() >= 1, "selector {selector}: window never empty"); + assert!(!w.is_empty(), "selector {selector}: window never empty"); let ps: Vec = w.pitches().iter().map(|p| p.0).collect(); assert!( ps.last().unwrap() - ps.first().unwrap() <= 12, @@ -184,7 +184,7 @@ fn every_anchor_index_is_a_nonempty_octave_window() { let ladder = ScaleLadder::build(&PitchRange::new(Pitch(28), Pitch(64)), &classes).expect("ok"); for anchor in 0..ladder.octave_window_count() { let w = ladder.octave_window(anchor); - assert!(w.len() >= 1, "anchor {anchor}: window never empty"); + assert!(!w.is_empty(), "anchor {anchor}: window never empty"); let ps: Vec = w.pitches().iter().map(|p| p.0).collect(); assert!( ps.last().unwrap() - ps.first().unwrap() <= 12, diff --git a/core/tests/wrap_free_traversal.rs b/core/tests/wrap_free_traversal.rs index 0110cfc0..fae771b4 100644 --- a/core/tests/wrap_free_traversal.rs +++ b/core/tests/wrap_free_traversal.rs @@ -193,7 +193,7 @@ fn rhythm_copy_union_reaches_low_middle_high() { .expect("generate"); union.extend(pitches(&c.score)); } - let mid = (u16::from(lo) + u16::from(hi)) / 2; + let mid = u16::midpoint(u16::from(lo), u16::from(hi)); assert!( union.iter().any(|&p| p <= lo + 4), "reaches the low register" diff --git a/docs/decisions.log.md b/docs/decisions.log.md index c3799bc9..3375c1f2 100644 --- a/docs/decisions.log.md +++ b/docs/decisions.log.md @@ -1642,3 +1642,14 @@ Architectural decisions go to [`adr/`](adr/) instead. confidence thresholds **not calibrated**; automatic scope selection **not approved**; generation integration **frozen**; cadence **frozen**; Phase 2 **not started**. + +- 2026-07-14 — In the context of an MSRV that no longer described reality, + facing `rust-version = "1.74"` in a workspace whose `egui`/`eframe` 0.34 + dependencies demand 1.92 (so no 1.74 user could ever have built the cockpit), + we decided for **raising the MSRV to 1.92** — the true floor the graph + imposes — and against both leaving the stale claim and jumping to current + stable, to achieve an MSRV that is honest and minimal rather than decorative, + accepting that the cockpit's dependency tree now dictates the number for every + crate in the workspace. Verified by building `--workspace --all-targets` on + 1.92; a CI job keeps the claim from rotting again. The original 1.74 was the + maintainer's own toolchain, a reason this log already recorded as spent. diff --git a/docs/glossary.md b/docs/glossary.md index 788a6ef0..cf5b3afa 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -1173,8 +1173,9 @@ lints. Pins the toolchain and components: stable, rustfmt, clippy. ### MSRV -Minimum Supported Rust Version. `griff` targets Rust 1.74; do not use newer -syntax/features without a decision. +Minimum Supported Rust Version. `griff` targets Rust 1.92 — the floor `egui` / +`eframe` 0.34 impose on the cockpit, verified by a CI job that builds the +workspace on it. Do not use newer syntax/features without a decision. ### clippy The Rust linter, configured strictly in `griff`. diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 2828b129..8312e0fb 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -10,7 +10,7 @@ name = "griff-fuzz" version = "0.0.0" publish = false edition = "2021" -rust-version = "1.74" +rust-version = "1.92" [package.metadata] cargo-fuzz = true diff --git a/ui-core/src/dock.rs b/ui-core/src/dock.rs index 0739c5e9..fe04ab07 100644 --- a/ui-core/src/dock.rs +++ b/ui-core/src/dock.rs @@ -6,6 +6,8 @@ //! filter and dashboard semantics cannot diverge between frontends (ADR-0016). //! No I/O — the caller hands in the chunks (read from the OPFS tree on web). +use core::cmp::Reverse; + use griff_core::corpus::{ChunkMeta, RightsStatus, StyleCohort, SwancoreTag}; /// A browse filter over the corpus — every field an optional facet. @@ -144,7 +146,7 @@ impl CorpusStats { .copied() .filter(|&(_, n)| n > 0) .collect(); - present.sort_by(|a, b| b.1.cmp(&a.1)); + present.sort_by_key(|&(_, n)| Reverse(n)); present } } diff --git a/ui-core/src/generate.rs b/ui-core/src/generate.rs index 523eee78..a3e1992c 100644 --- a/ui-core/src/generate.rs +++ b/ui-core/src/generate.rs @@ -152,7 +152,12 @@ fn notes(score: &Score) -> impl Iterator + '_ { #[cfg(test)] mod tests { - #![allow(clippy::expect_used, clippy::indexing_slicing)] + // Fixture arithmetic runs over a fixed `0..4`, so it cannot overflow. + #![allow( + clippy::arithmetic_side_effects, + clippy::expect_used, + clippy::indexing_slicing + )] use super::*; use griff_core::event::{NoteMarks, Pitch, Tempo, Ticks, TimeSignature, Tuning, Velocity}; diff --git a/ui-core/src/viewport.rs b/ui-core/src/viewport.rs index a5fa0754..0cebaae0 100644 --- a/ui-core/src/viewport.rs +++ b/ui-core/src/viewport.rs @@ -314,7 +314,7 @@ impl Viewport { /// Toggles the pending merge with the attached partner record; arming it /// disarms a pending split (the same one-rewrite-per-pass rule). - fn toggle_merge(&mut self, ctx: &ViewContext) { + const fn toggle_merge(&mut self, ctx: &ViewContext) { if ctx.can_merge { self.merging = !self.merging; if self.merging { @@ -361,7 +361,7 @@ impl Viewport { /// Recenters the scroll on the playhead when it leaves the visible window of /// `plot_cols` columns. A no-op when paused. - pub fn autoscroll(&mut self, plot_cols: u32) { + pub const fn autoscroll(&mut self, plot_cols: u32) { if !self.playing || plot_cols == 0 { return; }