diff --git a/README.md b/README.md index 0b1d4a0..b062865 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A pitch-tracking ring modulator audio plugin (VST3/AU/Standalone) built with JUCE 8.

- HDN Ring Modulator tracking an A2 at 110 Hz: cyan input-pitch display locked, Nixie counter showing a 220 Hz carrier at a 2x rate multiplier, and the Tracking, Carrier and Output controls + HDN Ring Modulator in the Broadcast finish tracking an A2 at 110 Hz: Nixie note tubes, a confidence VU meter with the lock jewel lit, a Nixie counter showing a 220 Hz carrier with its sine on a green scope, and the Tracking, Carrier and Output controls on a hammertone rack panel

## What Is This? @@ -22,8 +22,9 @@ Four oscillator waveforms are available (sine, triangle, square, saw), each prod The panel reads left to right in signal order: what the tracker hears, the carrier it drives, and the controls for each stage. -- **Input pitch** (cyan display): the detected note and frequency. **LOCK** lights only while the carrier is actually following the input. The confidence bar shows how certain the detector is, and the marker under it is the **Sensitivity** threshold a detection must clear. The display reads *LISTENING* while the tracker searches, *NO AUDIO* when the host is not processing, and *OFF* in Manual mode. -- **Carrier frequency** (Nixie counter): the frequency the carrier oscillator is running at, published by the processor rather than estimated in the UI, with a trace of the selected waveform. The tubes go dark while the effect stays dry. +- **Input pitch** (Nixie tubes): the detected note, with its frequency in the amber window underneath. The window reads *LISTENING* while the tracker searches, *NO AUDIO* when the host is not processing, and *MANUAL CARRIER* in Manual mode. +- **Confidence** meter: how certain the detector is. Its green zone starts at the **Sensitivity** threshold a detection must reach, and the **LOCK** jewel lights only while the carrier is actually following the input. +- **Carrier frequency** (Nixie counter): the frequency the carrier oscillator is running at, published by the processor rather than estimated in the UI, with the selected waveform on the scope. The tubes go dark while the effect stays dry. - **Source** keys switch between Pitch Track and Manual. Controls that do not act in the current source keep working but show it: their activity lamp goes out and their readout dims. - **Waveform** keys select the carrier shape. diff --git a/img/hdn-ring-modulator-ui.png b/img/hdn-ring-modulator-ui.png index 5707e62..ef6dd12 100644 Binary files a/img/hdn-ring-modulator-ui.png and b/img/hdn-ring-modulator-ui.png differ diff --git a/libs/affine-ui/DESIGN_LANGUAGE.md b/libs/affine-ui/DESIGN_LANGUAGE.md index 1af08c4..40051bd 100644 --- a/libs/affine-ui/DESIGN_LANGUAGE.md +++ b/libs/affine-ui/DESIGN_LANGUAGE.md @@ -1,14 +1,19 @@ # Affine design language -**Version 1.0** · shared by every Affine plug-in · implemented by the `affine_ui` JUCE module in this folder +**Version 2.0** · shared by every Affine plug-in · implemented by the `affine_ui` JUCE module in this folder -Affine plug-ins are **rendered laboratory instruments**. Each product is a believable piece of -precision hardware from the same bench: an anodised faceplate, machined knobs with calibrated -scales, backlit meters, glowing displays, all lit by one studio light. The hardware is a -vehicle for clarity, not decoration: every lamp, needle and digit reports real state. +Affine plug-ins are **rendered instruments**. Each product is a believable piece of +hardware from the same bench: a real faceplate, knobs with calibrated scales, lit meters +and displays, all lit by one studio light. The hardware is a vehicle for clarity, not +decoration: every lamp, needle and digit reports real state. + +The kit renders four **finishes**: Laboratory, Broadcast, Hi-Fi and Stealth. A finish +sets the materials, lamps and display technology. The principles, interaction contract, +states and telemetry rules below apply to every finish, and all products in a release +share one finish.

- Suppressor and HDN Ring Modulator side by side + Suppressor and HDN Ring Modulator in each of the four finishes

## Principles @@ -16,19 +21,19 @@ vehicle for clarity, not decoration: every lamp, needle and digit reports real s 1. **One light.** A single softbox sits above and slightly left of the player. Every highlight, reflection and shadow in every product agrees with it, so the instruments read as physical objects and sit together on screen. -2. **Real materials, rendered.** Anodised aluminium plate, spun-aluminium knob caps, - knurled black grips, a backlit meter card, smoked display glass, neon and phosphor. - Materials are shaded procedurally at the display's physical resolution, never - stretched bitmaps. +2. **Real materials, rendered.** Anodised aluminium, hammered enamel, powder coat and + black glass; spun and polished aluminium, bakelite and rubber; meter cards, Nixie + tubes, phosphor and LEDs. Materials are shaded procedurally at the display's physical + resolution, never stretched bitmaps. 3. **Truth before spectacle.** An instrument only shows what the processor actually did. - Stale readings go dark (lamps dim, needles rest, tubes extinguish); nothing is - extrapolated or animated for effect. Needle ballistics and glows are presentation only - and never feed back into processing. -4. **Printed like an instrument.** Labels are silkscreened, scales are calibrated through - the parameter's own mapping (a log control gets a log scale), and controls are grouped - by printed frames that follow the signal flow from left to right. -5. **One hardware kit.** Products share knobs, keys, lamps, meters, displays, screws and - typography. They differ in faceplate anodising, emission colour and layout. + Stale readings go dark (lamps dim, needles rest, tubes extinguish, plots break); + nothing is extrapolated or animated for effect. Needle ballistics and glows are + presentation only and never feed back into processing. +4. **Printed like an instrument.** Labels are printed on the front, scales are calibrated + through the parameter's own mapping (a log control gets a log scale), and controls are + grouped by stage in signal-flow order, left to right. +5. **One hardware kit.** Products share knobs, keys, lamps, meters, displays and + typography. Within a finish they differ only in colour and layout. 6. **Native behaviour is part of the design.** Every control has the same drag, fine, exact-entry, reset, keyboard and host-menu behaviour, and every edit is one host gesture. 7. **Accessible by construction.** Every control has a role, name, formatted value and @@ -44,7 +49,7 @@ vehicle for clarity, not decoration: every lamp, needle and digit reports real s │ ◎ ◎ │ │ WORDMARK primary mode / status │ header band │ DESCRIPTOR │ -│ ════════════════════════════════════════════════════════════════════ │ machined groove +│ ──────────────────────────────────────────────────────────────────── │ │ instrument windows: meters, counters, displays (the hero) │ what is happening │ │ │ ╭─ STAGE ───────────╮ ╭─ STAGE ─────────────────╮ ╭─ OUTPUT ─╮ │ what you can change, @@ -55,50 +60,69 @@ vehicle for clarity, not decoration: every lamp, needle and digit reports real s ``` - **Header band:** wordmark and descriptor on the left; the product's primary mode or - status on the right; a machined groove underneath. + status on the right. - **Instrument windows:** the largest area shows what the plug-in is doing. Choose the - display technology by the quantity (see below). -- **Controls:** printed frames group controls by stage, in signal-flow order. Knob axes in - a row share one horizontal line, as they would on a real panel. -- **Maker's mark** bottom left; four corner screws at a 14 px inset; 40 px outer margins. - -## Tokens - -### Shared palette - -| Role | Value | Use | + display technology by the quantity and the finish (see below). +- **Controls:** grouped by stage, in signal-flow order, with printed frames or titled + hairlines. Knob axes in a row share one horizontal line, as they would on a real panel. +- **Maker's mark** bottom left. How the front is held is part of the finish: corner + screws (Laboratory), rack ears (Broadcast), end caps (Hi-Fi), nothing visible (Stealth). + +## Finishes + +| | Laboratory | Broadcast | Hi-Fi | Stealth | +|---|---|---|---|---| +| Front | Dark anodised aluminium, bead-blasted or brushed | Light hammertone enamel with rack ears and a riveted name plate | Black glass between polished aluminium end caps | Matte black powder coat | +| Knobs | Spun-aluminium cap on a knurled black grip | Glossy bakelite with a ribbed skirt and a white pointer | Spun cap on a polished aluminium body | Dark spun cap, white pointer, white LED ring | +| Keys | Anodised caps with LED windows | Piano keys; a chrome bat-handle toggle | Polished square buttons whose legends light | Rubber soft keys with LED strips | +| Lamps | LEDs behind domed lenses | Faceted jewels in chrome bezels | Legends lit from behind the glass | LEDs | +| Displays | Backlit meter card, LED ladders, Nixie tubes, VFD | Chrome-bezel VU meters, Nixie tubes, a phosphor scope | Blue-lit flush meters, a tuning dial, VFD, horizontal ladders | One colour screen: history plots, scopes, bars and numerals | +| Readouts | Glowing characters on glass | Dark ink in amber-lit windows | Glowing characters on glass | Glowing characters on glass | +| Printing | Light silkscreen, Michroma wordmark | Dark ink; the wordmark engraved on the name plate | Printed on the back of the glass and lit; green wordmark | Light silkscreen; wide-tracked condensed wordmark | + +A finish is a `Theme`: `PanelFinish` (texture, base colour, grain, mottle, sheen, and +the hammertone dimple size), `KnobFinish` (cap and body materials and colours, cap and +grip proportions, dome, skirt ridges) and a `Palette` (printing, emission, lamp style, +readout style). Editors add the finish's fixing hardware and display choices. + +### Product colours + +| Finish | Suppressor | HDN Ring Modulator | |---|---|---| -| Silkscreen | `#ECE8DF` | Labels, legends, frames (at 30%), scale numbers | -| Silkscreen dim | `#AEAAA1` | Descriptors, secondary legends | -| Attention | `#FFB238` | Engaged monitoring or learning states, hot ladder segments | -| Danger | `#FF4A3D` | Clip cells only | -| Glass | `#07090B` | Display and readout windows | +| Laboratory | Graphite `#2A3037`; glacier cyan `#4FD6FF`; incandescent meter card `#F2E4C4` | Petrol `#1F3538`; neon orange `#FF6A1C` Nixie carrier; VFD cyan `#5CF2D6` input | +| Broadcast | Warm grey `#B3B0A6`; signal green `#2F9E4F`; VU card `#F2D48A` | Blue grey `#A9B0B5`; neon orange `#FF6A1C` Nixies; phosphor `#7DFFA0` scope | +| Hi-Fi | Black glass `#050607`; meter light `#3A8EF0`; green legends `#54F28C` | The same glass and meter light; blue `#4AA8FF` dial pointer and readout; green VFD | +| Stealth | Powder coat `#17181B`; white LEDs `#EEF4FF`; teal screen `#35E0C8` | The same coat and LEDs; amber screen `#FFB23F` | -Printed text is at least 4.5:1 against the plate (checked against the plate's darker lower -edge too) and emitted colours are at least 4.5:1 against glass; both are unit-tested. +## Tokens -### Product finishes +### Palette roles -A product chooses an anodising, one emission accent, and optionally a second display -technology. Everything else is shared. +Every finish defines the same roles; only the values change. -| Product | Anodising | Emission accent | Other emitters | -|---|---|---|---| -| Suppressor | Graphite `#2A3037` | Glacier cyan `#4FD6FF` | Incandescent meter card `#F2E4C4` | -| HDN Ring Modulator | Petrol `#1F3538` | Neon orange `#FF6A1C` (Nixie carrier) | VFD cyan `#5CF2D6` (input display) | +| Role | Use | +|---|---| +| Silkscreen | Labels, legends, frames, scale numbers | +| Silkscreen dim | Descriptors, secondary legends | +| Accent | The product's emission: lamps, displays, LED rings, focus | +| Attention | Amber `#FFB238` in every finish: engaged monitoring or learning states, hot segments | +| Danger | Red in every finish: clip cells only | +| Glass | Display and readout windows | +| Readout backlight and ink | Backlit readouts only (Broadcast) | -New products should pick an anodising that is distinct in hue but equally dark, and an -accent that stays at least 4.5:1 on glass. +Printed text is at least 4.5:1 against the plate (checked against the plate's darker lower +edge too). Emitted colours are at least 4.5:1 against glass, and backlit readouts' +ink at least 4.5:1 against their window. All three are unit-tested per product. ### Typography | Role | Face | Size | |---|---|---| -| Wordmark | Michroma, tracked 0.18 | 21 px | -| Frame titles, control labels | Barlow Condensed SemiBold, tracked 0.16–0.24 | 11.5–12.5 px, capitals | +| Wordmark | Michroma (Laboratory, Hi-Fi) or Barlow Condensed SemiBold tracked 0.36–0.45 (Broadcast, Stealth) | 21–30 px | +| Frame titles, control labels | Barlow Condensed SemiBold, tracked 0.16–0.26 | 11.5–12.5 px, capitals | | Scale numbers | Barlow Condensed SemiBold | 9.5–10 px | -| Readouts and status | Share Tech Mono | 13.5–15 px | -| VFD characters | DSEG14 Classic Bold | 40–48 px | +| Readouts and status | Share Tech Mono | 13.5–15 px; screen numerals up to 54 px | +| VFD characters | DSEG14 Classic Bold | 30–48 px | | Nixie cathodes | Nixie One, fitted to the cathode stack | tube height | All faces are bundled under the SIL Open Font License; see `fonts/licenses`. @@ -115,48 +139,54 @@ All faces are bundled under the SIL Open Font License; see `fonts/licenses`. ## Choosing a display -| Quantity | Instrument | Component | -|---|---|---| -| A magnitude that moves musically (gain reduction) | Backlit moving-coil meter with mirror scale | `NeedleMeter` | -| Peak level with clipping | LED ladder with a separate clip cell | `LadderMeter` | -| A frequency or count | Nixie tube counter | `NixieDisplay` | -| A symbol (note name) or short status | Vacuum-fluorescent characters | `DisplayLabel`, `GlowText` with DSEG14 | -| A set value | Glass readout under its knob | built into `Knob` | -| On/off state | Jewel lamp | `render::lamp` | +| Quantity | Laboratory | Broadcast | Hi-Fi | Stealth | +|---|---|---|---|---| +| A magnitude that moves musically (gain reduction) | Backlit `NeedleMeter` with mirror scale | Chrome-bezel VU `NeedleMeter` | Flush blue `NeedleMeter` | Scrolling history plot on the screen | +| Peak level with clipping | Vertical `LadderMeter` | VU `NeedleMeter` with clip jewel | Horizontal `LadderMeter` | Segmented bars on the screen | +| A frequency or count | `NixieDisplay` | `NixieDisplay` | `TuningDial` with a readout | Screen numerals | +| A note name or short status | `DisplayLabel`, `GlowText` in DSEG14 | Alphanumeric `NixieDisplay`; backlit `DisplayLabel` | VFD `GlowText`; `IlluminatedLegends` | Screen numerals and captions | +| A set value | Glass readout under its knob | Backlit readout under its knob | Glass readout under its knob | Glass readout under its knob | +| On/off state | LED `render::indicator` | Jewel `render::indicator` | Lit legend | LED | + +Screens are drawn by the product on `render::screenGlass`; they plot only stored readings +and leave gaps where there were none. ## Components | Component | Contract | |---|---| -| `Knob` | Rendered knob with printed calibrated scale, label and glass readout. Drag anywhere (up/right increases), Shift for fine, double-click or Return for exact entry (Return commits, Escape cancels, malformed text never changes the value), Alt/Option-click or Home resets, arrows step, right-click opens the parameter menu with host items. Optional activity lamp for *inactive but editable* states. | -| `KeyButton` | Latching key with an LED window and printed legend; Space and Return operate it. | -| `SelectorKeys` | One lit key per choice of a choice parameter, in a row or grid; each press is one complete gesture; arrows move the selection. | -| `NeedleMeter` | Lamp dims and needle rests when the reading is not live; spring-damper ballistics settle in about 300 ms. | -| `LadderMeter` | Accent / attention / danger zones, smooth top segment, clip cell apart from the scale. | -| `NixieDisplay` | Right-aligned digits, decimal cathode, ghost cathodes and honeycomb anode always visible; dark when the reading is absent. | -| `DisplayLabel` | A `juce::Label` that renders as a glowing display with a status lamp; the accessible text stays the label text. | -| `Faceplate` | Caches the plate texture, edge, screws and everything silkscreened on it per physical scale. | -| `silkscreen::*` | Wordmark, groove, frame, legend, maker's mark, screws. | +| `Knob` | Rendered knob with a printed calibrated scale or an LED ring, a label and a readout. Drag anywhere (up/right increases), Shift for fine, double-click or Return for exact entry (Return commits, Escape cancels, malformed text never changes the value), Alt/Option-click or Home resets, arrows step, right-click opens the parameter menu with host items. Optional activity lamp for *inactive but editable* states. | +| `KeyButton` | Latching key in one of five styles (anodised cap, piano key, bat-handle toggle, polished square, soft key) with a printed legend and optional glyph; Space and Return operate it. | +| `SelectorKeys` | One key per choice of a choice parameter, in a row or grid; each press is one complete gesture; arrows move the selection. | +| `NeedleMeter` | Moulded, chrome or flush housing; lamp dims and needle rests when the reading is not live; spring-damper ballistics (VU-like by default); optional coloured zone and centre rest position. | +| `LadderMeter` | Vertical or horizontal; accent, attention and danger zones; smooth top segment; clip cell apart from the scale. | +| `NixieDisplay` | Numeral or alphanumeric tubes; right-aligned text, decimal cathode, ghost cathodes and honeycomb anode always visible; dark when the reading is absent. | +| `TuningDial` | A lit frequency dial with a gliding pointer that parks at the left stop without a reading. | +| `IlluminatedLegends` | A `juce::Label` shown as a row of printed legends of which the current one lights; the accessible text stays the label text. | +| `DisplayLabel` | A `juce::Label` shown as glowing characters on glass or dark ink on a lit window, with a status lamp; the accessible text stays the label text. | +| `Faceplate` | Caches the plate texture, edge, screws and everything printed on it per physical scale. | +| `render::*` | Faceplate textures, screws, recesses, lit windows, jewels, rack ears, name plates, end caps, screen glass, lamps, glow and halos. | +| `silkscreen::*` | Wordmark, groove, frame, legend, lit legend, maker's mark, screws. | | `LookAndFeel` | Menus, tooltips and text entry in the family style. | ## States | State | Presentation | |---|---| -| Live | Lamps lit, needles move, digits glow | -| No data or stale (no processed audio for 400–500 ms) | Lamps dim, needle rests at its stop, tubes and segments go dark, readouts show `--` | -| Inactive but editable (the control does not act in the current mode) | Activity lamp off, label and readout dimmed; the control stays fully operable | -| Disabled | Knob veiled, scale dimmed; not operable | +| Live | Lamps lit, needles move, digits glow, plots advance | +| No data or stale (no processed audio for 400–500 ms) | Lamps dim, needles rest at their stop, tubes and segments go dark, readouts show `--`, plots break | +| Inactive but editable (the control does not act in the current mode) | Activity lamp off, label, readout and LED ring dimmed; the control stays fully operable | +| Disabled | Knob veiled, scale or ring dark; not operable | | Attention | Amber emission plus explicit text (e.g. `LISTENING TO DIFFERENCE`) | -| Clip | Red clip cell, held by the processor for one second, plus accessible text | +| Clip | Red clip cell or jewel, held by the processor for one second, plus accessible text | | Keyboard focus | Illuminated ring at the foot of the knob, outline around keys | ## Adding a product 1. Copy this folder unchanged into the plug-in as `libs/affine-ui`, `include()` its `AffineUI.cmake` after JUCE and link `affine_ui`. -2. Define the product's `affine::Theme`: anodising, accent, and any second emitter. -3. Lay out the header, instrument windows and framed control stages as above. +2. Define the product's `affine::Theme` in the release's finish, with its own colours. +3. Lay out the header, instrument windows and control stages as above. 4. Publish telemetry from the processor through atomics with a freshness signal (a block counter or sequence) and present stale data as dark. 5. Add tests for contrast, control mapping and focus order, and a capture test that writes diff --git a/libs/affine-ui/README.md b/libs/affine-ui/README.md index e7aebf7..64209ec 100644 --- a/libs/affine-ui/README.md +++ b/libs/affine-ui/README.md @@ -18,7 +18,8 @@ target_link_libraries(MyPlugin PRIVATE affine_ui) ```cpp #include -affine::Theme theme; // shared palette, per-product finish +affine::Theme theme; // the release's finish, in product colours +theme.panel.texture = affine::PanelFinish::Texture::anodised; theme.panel.base = juce::Colour (0xff2a3037); theme.palette.accent = juce::Colour (0xff4fd6ff); @@ -35,15 +36,16 @@ Requires JUCE 8 and C++17. The module depends on `juce_gui_basics` and | Path | What | |---|---| -| `affine_ui/render` | Procedural renderers: knob shader, anodised faceplate, glow and blur | -| `affine_ui/components` | `Knob`, `KeyButton`, `SelectorKeys`, `NeedleMeter`, `LadderMeter`, `NixieDisplay`, `DisplayLabel`, `Faceplate` and silkscreen helpers | +| `affine_ui/render` | Procedural renderers: knob materials, faceplate textures (anodised, hammertone, powder, glass), panel hardware, glow and blur | +| `affine_ui/components` | `Knob`, `KeyButton`, `SelectorKeys`, `NeedleMeter`, `LadderMeter`, `NixieDisplay`, `TuningDial`, `IlluminatedLegends`, `DisplayLabel`, `Faceplate` and silkscreen helpers | | `affine_ui/theme` | Palette, metrics, fonts, `LookAndFeel` | +| `docs/finishes.png` | Both products in each of the four finishes | | `fonts` | Barlow Condensed, Michroma, Nixie One, DSEG14 Classic, Share Tech Mono (all SIL OFL 1.1; licences in `fonts/licenses`) | -| `DESIGN_LANGUAGE.md` | Principles, tokens, anatomy, component contracts, states | +| `DESIGN_LANGUAGE.md` | Principles, finishes, tokens, anatomy, component contracts, states | ## Rendering notes -Knobs, the faceplate and meter faces are shaded per pixel at the display's physical +Knobs, faceplates and meter faces are shaded per pixel at the display's physical scale and cached, so they stay sharp at 100–200% and on Retina displays. Only the parts that move are redrawn: the knob's knurled grip band is re-shaded (about a millisecond at 2x) when its value changes, and meters repaint their own bounds. Nothing here runs on the diff --git a/libs/affine-ui/affine_ui/affine_ui.cpp b/libs/affine-ui/affine_ui/affine_ui.cpp index 35bb236..8661d89 100644 --- a/libs/affine-ui/affine_ui/affine_ui.cpp +++ b/libs/affine-ui/affine_ui/affine_ui.cpp @@ -20,4 +20,5 @@ #include "components/affine_Keys.cpp" #include "components/affine_Nixie.cpp" #include "components/affine_Faceplate.cpp" +#include "components/affine_Legends.cpp" #include "theme/affine_LookAndFeel.cpp" diff --git a/libs/affine-ui/affine_ui/affine_ui.h b/libs/affine-ui/affine_ui/affine_ui.h index 63df2e8..92583aa 100644 --- a/libs/affine-ui/affine_ui/affine_ui.h +++ b/libs/affine-ui/affine_ui/affine_ui.h @@ -3,7 +3,7 @@ ID: affine_ui vendor: affine - version: 1.0.0 + version: 2.0.0 name: Affine instrument UI description: Shared rendered-instrument design language for Affine plug-ins license: GPL-3.0-or-later @@ -26,6 +26,7 @@ #include #include #include +#include #include #include "render/affine_Shading.h" @@ -41,4 +42,5 @@ #include "components/affine_Keys.h" #include "components/affine_Nixie.h" #include "components/affine_Faceplate.h" +#include "components/affine_Legends.h" #include "theme/affine_LookAndFeel.h" diff --git a/libs/affine-ui/affine_ui/components/affine_Displays.cpp b/libs/affine-ui/affine_ui/components/affine_Displays.cpp index f1451c5..5e0ba86 100644 --- a/libs/affine-ui/affine_ui/components/affine_Displays.cpp +++ b/libs/affine-ui/affine_ui/components/affine_Displays.cpp @@ -28,6 +28,14 @@ void lamp (juce::Graphics& g, juce::Point centre, float diameter, juce::C g.fillEllipse (juce::Rectangle (r * 0.62f, r * 0.42f).withCentre (centre.translated (-r * 0.28f, -r * 0.40f))); } +void indicator (juce::Graphics& g, juce::Point centre, float diameter, juce::Colour colour, float level, const Palette& palette) +{ + if (palette.lamp == Palette::Lamp::jewel) + jewel (g, centre, diameter, colour, level); + else + lamp (g, centre, diameter, colour, level); +} + void glass (juce::Graphics& g, juce::Rectangle area, const Palette& palette, float corner) { juce::ColourGradient body (palette.glassTint, area.getX(), area.getY(), @@ -84,6 +92,12 @@ void DisplayLabel::setShowsLamp (bool shouldShow) repaint(); } +void DisplayLabel::setBacklit (bool shouldBeBacklit) +{ + backlit = shouldBeBacklit; + repaint(); +} + void DisplayLabel::setGlassVisible (bool shouldShow) { glassVisible = shouldShow; @@ -98,6 +112,16 @@ void DisplayLabel::setDisplayFont (const juce::Font& f) void DisplayLabel::paint (juce::Graphics& g) { + if (backlit) + { + const auto window = getLocalBounds().toFloat().reduced (2.0f); + render::litWindow (g, window, emission, lampLevel); + g.setColour (theme.palette.readoutInk.withMultipliedAlpha (0.35f + 0.55f * lampLevel)); + g.setFont (font); + g.drawText (getText().toUpperCase(), window.reduced (8.0f, 0.0f), getJustificationType(), false); + return; + } + auto area = getLocalBounds().toFloat(); if (glassVisible) render::glass (g, area, theme.palette); @@ -119,7 +143,7 @@ void DisplayLabel::paint (juce::Graphics& g) const auto textWidth = glyphs.getBoundingBox (0, -1, true).getWidth(); lampSlot = lampSlot.withX (area.getRight() - textWidth - 19.0f); } - render::lamp (g, lampSlot.getCentre(), 6.0f, lampColour.isTransparent() ? emission : lampColour, lampLevel); + render::indicator (g, lampSlot.getCentre(), 6.0f, lampColour.isTransparent() ? emission : lampColour, lampLevel, theme.palette); area.removeFromLeft (7.0f); } diff --git a/libs/affine-ui/affine_ui/components/affine_Displays.h b/libs/affine-ui/affine_ui/components/affine_Displays.h index 4c3165b..a27e277 100644 --- a/libs/affine-ui/affine_ui/components/affine_Displays.h +++ b/libs/affine-ui/affine_ui/components/affine_Displays.h @@ -9,6 +9,9 @@ void lamp (juce::Graphics&, juce::Point centre, float diameter, juce::Col /** A glass display window cut into the faceplate. */ void glass (juce::Graphics&, juce::Rectangle area, const Palette&, float corner = metrics::displayCorner); + +/** An indicator in the palette's lamp style: an LED lens or a jewel. */ +void indicator (juce::Graphics&, juce::Point centre, float diameter, juce::Colour colour, float level, const Palette&); } // namespace render /** @@ -26,6 +29,8 @@ class DisplayLabel : public juce::Label void setLampColour (juce::Colour); void setShowsLamp (bool); void setGlassVisible (bool); + /** Dark lettering on a window lit in the emission colour, as on an illuminated legend. */ + void setBacklit (bool); void setDisplayFont (const juce::Font&); void paint (juce::Graphics&) override; @@ -35,7 +40,7 @@ class DisplayLabel : public juce::Label juce::Colour emission, lampColour; juce::Font font; float lampLevel = 0.0f; - bool showsLamp = true, glassVisible = true; + bool showsLamp = true, glassVisible = true, backlit = false; render::GlowText glow; }; } // namespace affine diff --git a/libs/affine-ui/affine_ui/components/affine_Faceplate.cpp b/libs/affine-ui/affine_ui/components/affine_Faceplate.cpp index fa79fcc..e4290ba 100644 --- a/libs/affine-ui/affine_ui/components/affine_Faceplate.cpp +++ b/libs/affine-ui/affine_ui/components/affine_Faceplate.cpp @@ -19,7 +19,8 @@ void Faceplate::paint (juce::Graphics& g, juce::Rectangle bounds, const The layer.setColour (juce::Colours::black.withAlpha (0.45f)); layer.fillRect (edge.withTop (edge.getBottom() - 1.0f)); - silkscreen::screws (layer, edge); + if (screws) + silkscreen::screws (layer, edge); if (print != nullptr) print (layer); } @@ -121,5 +122,12 @@ void screws (juce::Graphics& g, juce::Rectangle bounds) for (int i = 0; i < 4; ++i) render::screw (g, corners[i], metrics::screwDiameter, turns[i]); } + +void litLegend (juce::Graphics& g, const juce::String& text, juce::Rectangle area, juce::Colour colour, + const juce::Font& font, juce::Justification justification, float glow) +{ + render::GlowText lit; + lit.draw (g, text, font, area, justification, colour, 2.4f, glow); +} } // namespace silkscreen } // namespace affine diff --git a/libs/affine-ui/affine_ui/components/affine_Faceplate.h b/libs/affine-ui/affine_ui/components/affine_Faceplate.h index 39a1fa8..f291c66 100644 --- a/libs/affine-ui/affine_ui/components/affine_Faceplate.h +++ b/libs/affine-ui/affine_ui/components/affine_Faceplate.h @@ -18,7 +18,11 @@ class Faceplate /** Forces the next paint to rebuild, e.g. after printed text changed. */ void invalidate() noexcept { cache = {}; } + /** Corner screws; off for fronts that are held some other way (rack ears, glass). */ + void setShowsScrews (bool shouldShow) { screws = shouldShow; cache = {}; } + private: + bool screws = true; juce::Image cache; juce::Rectangle cachedBounds; float cachedScale = 0.0f; @@ -45,5 +49,9 @@ void legend (juce::Graphics&, const juce::String&, juce::Rectangle, const /** Four corner screws. */ void screws (juce::Graphics&, juce::Rectangle bounds); + +/** A legend printed on the back of a glass front and lit from behind: it glows softly. */ +void litLegend (juce::Graphics&, const juce::String&, juce::Rectangle, juce::Colour, const juce::Font&, + juce::Justification = juce::Justification::centredLeft, float glow = 0.6f); } // namespace silkscreen } // namespace affine diff --git a/libs/affine-ui/affine_ui/components/affine_Keys.cpp b/libs/affine-ui/affine_ui/components/affine_Keys.cpp index d9e5a9b..7837f7a 100644 --- a/libs/affine-ui/affine_ui/components/affine_Keys.cpp +++ b/libs/affine-ui/affine_ui/components/affine_Keys.cpp @@ -13,6 +13,18 @@ void KeyButton::setTheme (const Theme& t) repaint(); } +void KeyButton::setStyle (Style s) +{ + style = s; + repaint(); +} + +void KeyButton::setKeyColour (juce::Colour c) +{ + keyColour = c; + repaint(); +} + void KeyButton::setLampColour (juce::Colour c) { lampColour = c; @@ -77,6 +89,54 @@ bool KeyButton::keyPressed (const juce::KeyPress& key) } void KeyButton::paintButton (juce::Graphics& g, bool highlighted, bool down) +{ + switch (style) + { + case Style::pianoKey: paintPianoKey (g, highlighted, down); break; + case Style::toggle: paintToggle (g, highlighted, down); break; + case Style::silverSquare: paintSilverSquare (g, highlighted, down); break; + case Style::softKey: paintSoftKey (g, highlighted, down); break; + case Style::cap: + default: paintCap (g, highlighted, down); break; + } +} + +void KeyButton::paintGlyph (juce::Graphics& g, juce::Rectangle area, juce::Colour colour, float width) +{ + if (glyph.isEmpty()) + return; + auto shape = glyph; + shape.applyTransform (shape.getTransformToScaleToFit (area, false)); + g.setColour (colour); + g.strokePath (shape, juce::PathStrokeType (width, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); +} + +void KeyButton::paintLegend (juce::Graphics& g, bool lit) +{ + const auto& palette = theme.palette; + auto legend = getLocalBounds().toFloat(); + legend = legend.removeFromBottom (legendTwo.isNotEmpty() ? 30.0f : 18.0f); + if (lit) + { + // Legend printed on a lamp window: it glows while the key is engaged. + render::halo (g, legend.withHeight (15.0f).getCentre(), legend.getWidth() * 0.45f, lampColour, 0.22f); + g.setColour (lampColour.interpolatedWith (juce::Colours::white, 0.25f)); + } + else + { + g.setColour (palette.silkscreen); + } + g.setFont (fonts::label (12.0f, 0.16f)); + g.drawText (legendOne.toUpperCase(), legend.removeFromTop (15.0f), juce::Justification::centred, false); + if (legendTwo.isNotEmpty()) + { + g.setColour (palette.silkscreenDim); + g.setFont (fonts::label (10.0f, 0.18f)); + g.drawText (legendTwo.toUpperCase(), legend.removeFromTop (13.0f), juce::Justification::centred, false); + } +} + +void KeyButton::paintCap (juce::Graphics& g, bool highlighted, bool down) { const auto& palette = theme.palette; const auto key = getKeyBounds(); @@ -118,34 +178,208 @@ void KeyButton::paintButton (juce::Graphics& g, bool highlighted, bool down) g.fillRoundedRectangle (window.reduced (window.getWidth() * 0.2f, 1.2f), 1.0f); } + paintGlyph (g, cap.withTrimmedTop (cap.getHeight() * 0.50f).reduced (cap.getWidth() * 0.26f, cap.getHeight() * 0.12f), + palette.silkscreen.withAlpha (engaged ? 0.95f : 0.72f), 1.4f); + + if (hasKeyboardFocus (true)) + { + g.setColour (palette.accent.withAlpha (0.9f)); + g.drawRoundedRectangle (well.expanded (2.5f), 8.0f, 1.5f); + } + paintLegend (g, false); +} + +void KeyButton::paintPianoKey (juce::Graphics& g, bool highlighted, bool down) +{ + const auto& palette = theme.palette; + const auto key = getKeyBounds(); + const auto engaged = getToggleState(); + // An interlocking key stays down while engaged; the others stand proud. + const auto travel = down ? 4.0f : (engaged ? 3.0f : 0.0f); + const auto lip = 5.0f - travel; + + // Slot in the plate. + const auto slot = key.expanded (2.0f, 2.0f).withTrimmedBottom (-4.0f); + g.setColour (juce::Colour (0xff0c0b0a).withAlpha (0.85f)); + g.fillRoundedRectangle (slot, 2.5f); + + const auto top = key.translated (0.0f, travel).withTrimmedBottom (juce::jmax (0.0f, lip)); + const auto front = juce::Rectangle (top.getX(), top.getBottom(), top.getWidth(), juce::jmax (0.0f, lip)); + if (! engaged) + render::softShadow (g, top.withTrimmedTop (top.getHeight() * 0.5f), 2.0f, 5.0f, { 0.0f, 5.0f }, 0.45f); + + // Front face of the key, then the rounded top. + g.setColour (keyColour.darker (0.9f)); + g.fillRoundedRectangle (front.expanded (0.0f, 1.5f), 2.0f); + const auto shade = engaged ? 0.22f : 0.0f; + juce::ColourGradient body (keyColour.brighter (0.10f).darker (shade), top.getX(), top.getY(), + keyColour.darker (0.18f + shade), top.getX(), top.getBottom(), false); + g.setGradientFill (body); + g.fillRoundedRectangle (top, 2.5f); + g.setColour (juce::Colours::white.withAlpha (highlighted ? 0.45f : 0.28f)); + g.drawLine (top.getX() + 2.0f, top.getY() + 0.8f, top.getRight() - 2.0f, top.getY() + 0.8f, 1.0f); + g.setColour (juce::Colours::black.withAlpha (0.35f)); + g.drawRoundedRectangle (top, 2.5f, 0.8f); + + const auto ink = keyColour.getPerceivedBrightness() > 0.5f ? juce::Colour (0xff1b1a18) : juce::Colour (0xffece8df); if (! glyph.isEmpty()) + paintGlyph (g, top.reduced (top.getWidth() * 0.22f, top.getHeight() * 0.30f), ink.withAlpha (0.85f), 1.5f); + + if (hasKeyboardFocus (true)) + { + g.setColour (palette.accent.withAlpha (0.9f)); + g.drawRoundedRectangle (slot.expanded (2.5f), 4.0f, 1.5f); + } + paintLegend (g, false); +} + +void KeyButton::paintToggle (juce::Graphics& g, bool highlighted, bool down) +{ + const auto& palette = theme.palette; + auto bounds = getLocalBounds().toFloat(); + const auto upLegend = bounds.removeFromTop (16.0f); + const auto downLegend = bounds.removeFromBottom (16.0f); + const auto centre = bounds.getCentre(); + const auto size = juce::jmin (bounds.getWidth(), bounds.getHeight() * 0.62f, 56.0f); + const auto engaged = getToggleState(); + + // Printed positions: up is the engaged state. + g.setFont (fonts::label (11.5f, 0.18f)); + g.setColour (palette.silkscreen.withMultipliedAlpha (engaged ? 1.0f : 0.72f)); + g.drawText (legendOne.toUpperCase(), upLegend, juce::Justification::centred, false); + g.setColour (palette.silkscreen.withMultipliedAlpha (engaged ? 0.72f : 1.0f)); + g.drawText (legendTwo.toUpperCase(), downLegend, juce::Justification::centred, false); + + // Washer and hex nut holding the switch to the plate. + const auto washer = juce::Rectangle (size * 0.62f, size * 0.62f).withCentre (centre); + render::softShadow (g, washer, washer.getWidth() * 0.5f, 3.0f, { 0.0f, 1.5f }, 0.45f); + juce::ColourGradient ring (juce::Colour (0xffe9ecef), washer.getX(), washer.getY(), + juce::Colour (0xff5a5e64), washer.getRight(), washer.getBottom(), false); + g.setGradientFill (ring); + g.fillEllipse (washer); + juce::Path nut; + const auto nutRadius = size * 0.24f; + for (int i = 0; i < 6; ++i) { - const auto area = cap.withTrimmedTop (cap.getHeight() * 0.50f).reduced (cap.getWidth() * 0.26f, cap.getHeight() * 0.12f); - auto shape = glyph; - shape.applyTransform (shape.getTransformToScaleToFit (area, false)); - g.setColour (palette.silkscreen.withAlpha (engaged ? 0.95f : 0.72f)); - g.strokePath (shape, juce::PathStrokeType (1.4f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); + const auto a = static_cast (i) * juce::MathConstants::twoPi / 6.0f + 0.26f; + const auto p = centre + juce::Point (std::cos (a), std::sin (a)) * nutRadius; + if (i == 0) nut.startNewSubPath (p); else nut.lineTo (p); } + nut.closeSubPath(); + juce::ColourGradient nutShade (juce::Colour (0xfff7f8f9), centre.x - nutRadius, centre.y - nutRadius, + juce::Colour (0xff484c52), centre.x + nutRadius, centre.y + nutRadius, false); + nutShade.addColour (0.5, juce::Colour (0xffa9aeb4)); + g.setGradientFill (nutShade); + g.fillPath (nut); + g.setColour (juce::Colours::black.withAlpha (0.45f)); + g.strokePath (nut, juce::PathStrokeType (0.8f)); + + // Bat handle: a tapered chrome lever, seen from the front, thrown up or down. + const auto direction = engaged ? -1.0f : 1.0f; + const auto press = down ? 0.9f : 1.0f; + const auto length = size * 0.62f * press; + const auto tip = centre.translated (0.0f, direction * length); + const auto baseWidth = size * 0.17f, tipWidth = size * 0.24f; + juce::Path bat; + bat.startNewSubPath (centre.x - baseWidth * 0.5f, centre.y); + bat.lineTo (tip.x - tipWidth * 0.36f, tip.y); + bat.lineTo (tip.x + tipWidth * 0.36f, tip.y); + bat.lineTo (centre.x + baseWidth * 0.5f, centre.y); + bat.closeSubPath(); + bat.addEllipse (juce::Rectangle (tipWidth, tipWidth * 0.82f).withCentre (tip)); + + g.setColour (juce::Colours::black.withAlpha (0.35f)); + g.fillPath (bat, juce::AffineTransform::translation (size * 0.05f, size * 0.10f)); + juce::ColourGradient chrome (juce::Colour (0xff6d7177), tip.x - tipWidth * 0.5f, tip.y, + juce::Colour (0xff4a4e53), tip.x + tipWidth * 0.5f, tip.y, false); + chrome.addColour (0.30, juce::Colour (0xfffbfcfd)); + chrome.addColour (0.55, juce::Colour (0xffaeb3b9)); + g.setGradientFill (chrome); + g.fillPath (bat); + g.setColour (juce::Colours::white.withAlpha (highlighted ? 0.9f : 0.7f)); + g.fillEllipse (juce::Rectangle (tipWidth * 0.36f, tipWidth * 0.22f).withCentre (tip.translated (-tipWidth * 0.14f, -tipWidth * 0.12f))); + g.setColour (juce::Colours::black.withAlpha (0.4f)); + g.strokePath (bat, juce::PathStrokeType (0.7f)); if (hasKeyboardFocus (true)) { g.setColour (palette.accent.withAlpha (0.9f)); - g.drawRoundedRectangle (well.expanded (2.5f), 8.0f, 1.5f); + g.drawEllipse (washer.expanded (4.0f), 1.5f); } +} - // Printed legend under the key. - auto legend = getLocalBounds().toFloat(); - legend = legend.removeFromBottom (legendTwo.isNotEmpty() ? 30.0f : 18.0f); - g.setColour (palette.silkscreen); - g.setFont (fonts::label (12.0f, 0.16f)); - g.drawText (legendOne.toUpperCase(), legend.removeFromTop (15.0f), juce::Justification::centred, false); - if (legendTwo.isNotEmpty()) +void KeyButton::paintSilverSquare (juce::Graphics& g, bool highlighted, bool down) +{ + const auto& palette = theme.palette; + const auto key = getKeyBounds(); + const auto engaged = getToggleState(); + const auto cap = key.reduced (down ? 1.5f : (engaged ? 0.8f : 0.0f)); + + render::softShadow (g, cap, 3.0f, 5.0f, { 0.0f, engaged ? 1.5f : 3.0f }, 0.7f); + // Polished aluminium: bright where it faces the softbox, a dark band where it mirrors the room. + juce::ColourGradient metal (juce::Colour (0xfff2f4f6), cap.getX(), cap.getY(), + juce::Colour (0xff7b8087), cap.getX(), cap.getBottom(), false); + metal.addColour (0.45, juce::Colour (0xffc7ccd1)); + metal.addColour (0.62, juce::Colour (0xff8f949a)); + g.setGradientFill (metal); + g.fillRoundedRectangle (cap, 2.5f); + // Fine vertical brushing. + g.setColour (juce::Colours::black.withAlpha (0.05f)); + for (float x = cap.getX() + 1.5f; x < cap.getRight() - 1.0f; x += 1.7f) + g.drawVerticalLine (juce::roundToInt (x), cap.getY() + 1.0f, cap.getBottom() - 1.0f); + g.setColour (juce::Colours::white.withAlpha (highlighted ? 0.95f : 0.75f)); + g.drawLine (cap.getX() + 1.5f, cap.getY() + 0.7f, cap.getRight() - 1.5f, cap.getY() + 0.7f, 1.0f); + g.setColour (juce::Colours::black.withAlpha (engaged ? 0.55f : 0.35f)); + g.drawRoundedRectangle (cap, 2.5f, 0.8f); + if (engaged) { - g.setColour (palette.silkscreenDim); - g.setFont (fonts::label (10.0f, 0.18f)); - g.drawText (legendTwo.toUpperCase(), legend.removeFromTop (13.0f), juce::Justification::centred, false); + g.setColour (juce::Colours::black.withAlpha (0.12f)); + g.fillRoundedRectangle (cap, 2.5f); + } + + paintGlyph (g, cap.reduced (cap.getWidth() * 0.24f, cap.getHeight() * 0.30f), juce::Colour (0xff1c1d1f).withAlpha (0.8f), 1.4f); + + if (hasKeyboardFocus (true)) + { + g.setColour (palette.accent.withAlpha (0.9f)); + g.drawRoundedRectangle (cap.expanded (4.0f), 5.0f, 1.5f); + } + paintLegend (g, engaged); +} + +void KeyButton::paintSoftKey (juce::Graphics& g, bool highlighted, bool down) +{ + const auto& palette = theme.palette; + const auto key = getKeyBounds(); + const auto engaged = getToggleState(); + const auto cap = key.reduced (down ? 1.0f : 0.0f); + + render::softShadow (g, cap, 6.0f, 6.0f, { 0.0f, 2.5f }, 0.65f); + juce::ColourGradient rubber (juce::Colour (0xff34363b), cap.getX(), cap.getY(), + juce::Colour (0xff1c1d20), cap.getX(), cap.getBottom(), false); + g.setGradientFill (rubber); + g.fillRoundedRectangle (cap, 6.0f); + g.setColour (juce::Colours::white.withAlpha (highlighted ? 0.14f : 0.08f)); + g.drawRoundedRectangle (cap.reduced (0.5f), 6.0f, 1.0f); + + // LED strip along the top edge of the key. + const auto strip = juce::Rectangle (cap.getWidth() * 0.5f, 3.0f).withCentre ({ cap.getCentreX(), cap.getY() + 6.0f }); + g.setColour (engaged ? lampColour : lampColour.withMultipliedSaturation (0.4f).withMultipliedBrightness (0.25f)); + g.fillRoundedRectangle (strip, 1.5f); + if (engaged) + render::halo (g, strip.getCentre(), strip.getWidth() * 0.9f, lampColour, 0.5f); + + paintGlyph (g, cap.withTrimmedTop (12.0f).reduced (cap.getWidth() * 0.26f, cap.getHeight() * 0.14f), + juce::Colour (0xffeceef0).withAlpha (engaged ? 0.95f : 0.65f), 1.4f); + + if (hasKeyboardFocus (true)) + { + g.setColour (palette.accent.withAlpha (0.9f)); + g.drawRoundedRectangle (cap.expanded (3.0f), 8.0f, 1.5f); } + paintLegend (g, false); } + //============================================================================== SelectorKeys::SelectorKeys (juce::AudioProcessorValueTreeState& state, const juce::String& parameterID) : parameter (*state.getParameter (parameterID)), @@ -217,6 +451,18 @@ void SelectorKeys::setKeySize (float width, float height) key->setKeySize (width, height); } +void SelectorKeys::setStyle (KeyButton::Style style) +{ + for (auto* key : keys) + key->setStyle (style); +} + +void SelectorKeys::setKeyColour (juce::Colour colour) +{ + for (auto* key : keys) + key->setKeyColour (colour); +} + void SelectorKeys::select (int index) { index = juce::jlimit (0, keys.size() - 1, index); diff --git a/libs/affine-ui/affine_ui/components/affine_Keys.h b/libs/affine-ui/affine_ui/components/affine_Keys.h index 7c018fa..3d6937f 100644 --- a/libs/affine-ui/affine_ui/components/affine_Keys.h +++ b/libs/affine-ui/affine_ui/components/affine_Keys.h @@ -3,15 +3,27 @@ namespace affine { /** - A latching push key with an LED window, for engaged/disengaged states. - Space and Return operate it; the printed legend sits below the key. + A latching key or switch for engaged/disengaged states. Space and Return + operate it; the printed legend sits below the key (above and below a toggle). */ class KeyButton : public juce::Button { public: + enum class Style + { + cap, // anodised key cap with an LED window + pianoKey, // interlocking push key that stays down while engaged + toggle, // chrome bat-handle toggle: up is engaged + silverSquare, // polished square push button whose legend lights while engaged + softKey // rubber key with an LED strip + }; + explicit KeyButton (const juce::String& name); void setTheme (const Theme&); + void setStyle (Style); + /** Colour of a piano key's top. */ + void setKeyColour (juce::Colour); void setLampColour (juce::Colour); void setLegend (const juce::String& lineOne, const juce::String& lineTwo = {}); /** A glyph printed on the key cap, fitted inside its lower half. */ @@ -27,8 +39,17 @@ class KeyButton : public juce::Button void paintButton (juce::Graphics&, bool highlighted, bool down) override; private: + void paintCap (juce::Graphics&, bool highlighted, bool down); + void paintPianoKey (juce::Graphics&, bool highlighted, bool down); + void paintToggle (juce::Graphics&, bool highlighted, bool down); + void paintSilverSquare (juce::Graphics&, bool highlighted, bool down); + void paintSoftKey (juce::Graphics&, bool highlighted, bool down); + void paintGlyph (juce::Graphics&, juce::Rectangle cap, juce::Colour, float width); + void paintLegend (juce::Graphics&, bool lit); + Theme theme; - juce::Colour lampColour; + Style style = Style::cap; + juce::Colour lampColour, keyColour { 0xffeae3d0 }; juce::String legendOne, legendTwo; juce::Path glyph; float keyWidth = 58.0f, keyHeight = 40.0f; @@ -50,6 +71,8 @@ class SelectorKeys : public juce::Component void setLegends (const juce::StringArray&); void setGlyphs (const std::vector&); void setKeySize (float width, float height); + void setStyle (KeyButton::Style); + void setKeyColour (juce::Colour); /** Keys are laid out left to right, wrapping after this many columns. */ void setColumns (int); int getSelectedIndex() const noexcept { return selected; } diff --git a/libs/affine-ui/affine_ui/components/affine_Knob.cpp b/libs/affine-ui/affine_ui/components/affine_Knob.cpp index 614fd68..3f5738b 100644 --- a/libs/affine-ui/affine_ui/components/affine_Knob.cpp +++ b/libs/affine-ui/affine_ui/components/affine_Knob.cpp @@ -97,9 +97,16 @@ void Knob::setShowsActivityLamp (bool shouldShow) repaint(); } +void Knob::setLedRing (bool shouldShow) +{ + ledRing = shouldShow; + repaint(); +} + juce::Point Knob::getPreferredSize() const { - return { juce::roundToInt (diameter + 60.0f), + // An LED ring pushes the printed numbers further out. + return { juce::roundToInt (diameter + (ledRing ? 76.0f : 60.0f)), juce::roundToInt (labelHeight + scaleMargin + diameter + readoutGap + readoutHeight + 2.0f) }; } @@ -153,8 +160,8 @@ void Knob::paint (juce::Graphics& g) juce::GlyphArrangement glyphs; glyphs.addLineOfText (g.getCurrentFont(), text, 0.0f, 0.0f); const auto textWidth = glyphs.getBoundingBox (0, -1, true).getWidth(); - render::lamp (g, { geometry.label.getCentreX() - textWidth * 0.5f - 9.0f, geometry.label.getCentreY() - 0.5f }, - 4.5f, palette.accent, live ? 1.0f : 0.0f); + render::indicator (g, { geometry.label.getCentreX() - textWidth * 0.5f - 9.0f, geometry.label.getCentreY() - 0.5f }, + 4.5f, palette.accent, live ? 1.0f : 0.0f, palette); } } @@ -177,12 +184,14 @@ void Knob::paint (juce::Graphics& g) g.drawImageTransformed (renderer.getGrip (angle), place (origin.x, origin.y)); const auto r = geometry.radius; + const auto& finish = theme.knob; + const auto pointerEnd = r * (finish.capRatio - 0.07f); g.setColour (juce::Colours::black.withAlpha (0.55f)); - strokeRadial (g, geometry.centre.translated (0.0f, 0.45f), angle, r * 0.15f, r * 0.53f, r * 0.070f + 0.8f); - g.setColour (theme.knob.pointer); - strokeRadial (g, geometry.centre, angle, r * 0.15f, r * 0.53f, r * 0.052f); - g.setColour (theme.knob.index); - strokeRadial (g, geometry.centre, angle, r * 0.85f, r * 0.955f, juce::jmax (1.2f, r * 0.05f)); + strokeRadial (g, geometry.centre.translated (0.0f, 0.45f), angle, r * 0.15f, pointerEnd, r * 0.070f + 0.8f); + g.setColour (finish.pointer); + strokeRadial (g, geometry.centre, angle, r * 0.15f, pointerEnd, r * 0.052f); + g.setColour (finish.index); + strokeRadial (g, geometry.centre, angle, r * (finish.gripRatio + 0.05f), r * 0.955f, juce::jmax (1.2f, r * 0.05f)); if (! enabled) { @@ -205,9 +214,6 @@ void Knob::paint (juce::Graphics& g) void Knob::paintScale (juce::Graphics& g, const Geometry& geometry) { - if (scaleValues.empty()) - return; - const auto& palette = theme.palette; const auto rotary = getRotaryParameters(); const auto r = geometry.radius; @@ -218,26 +224,64 @@ void Knob::paintScale (juce::Graphics& g, const Geometry& geometry) return rotary.startAngleRadians + p * (rotary.endAngleRadians - rotary.startAngleRadians); }; + if (ledRing) + { + // LEDs light from the start of the sweep up to the value; the last one fades in. + constexpr int count = 23; + const auto proportion = static_cast (valueToProportionOfLength (getValue())); + const auto live = enabled && ! inactive; + const auto colour = inactive ? palette.accent.withMultipliedSaturation (0.2f).withMultipliedBrightness (0.7f) : palette.accent; + for (int i = 0; i < count; ++i) + { + const auto t = static_cast (i) / static_cast (count - 1); + const auto angle = rotary.startAngleRadians + t * (rotary.endAngleRadians - rotary.startAngleRadians); + const auto at = geometry.centre.getPointOnCircumference (r + 7.5f, angle); + const auto lit = enabled ? juce::jlimit (0.0f, 1.0f, (proportion - t) * static_cast (count - 1) + 1.0f) : 0.0f; + g.setColour (juce::Colours::black.withAlpha (0.7f)); + g.fillEllipse (juce::Rectangle (4.4f, 4.4f).withCentre (at)); + if (lit > 0.0f) + { + if (live) + render::halo (g, at, 6.5f, colour, 0.5f * lit); + g.setColour (colour.withAlpha (live ? lit : lit * 0.55f)); + } + else + { + g.setColour (colour.withMultipliedSaturation (0.3f).withMultipliedBrightness (0.18f)); + } + g.fillEllipse (juce::Rectangle (3.0f, 3.0f).withCentre (at)); + } + } + + if (scaleValues.empty()) + return; + const auto majorColour = palette.silkscreen.withMultipliedAlpha (enabled ? 0.92f : 0.45f); const auto minorColour = palette.silkscreen.withMultipliedAlpha (enabled ? 0.50f : 0.25f); - for (size_t i = 0; i + 1 < scaleValues.size(); ++i) + if (! ledRing) { - const auto a = scaleValues[i], b = scaleValues[i + 1]; - g.setColour (minorColour); - for (int k = 1; k < 4; ++k) - strokeRadial (g, geometry.centre, angleFor (a + (b - a) * k / 4.0), r + 3.6f, r + 6.0f, 0.9f); + for (size_t i = 0; i + 1 < scaleValues.size(); ++i) + { + const auto a = scaleValues[i], b = scaleValues[i + 1]; + g.setColour (minorColour); + for (int k = 1; k < 4; ++k) + strokeRadial (g, geometry.centre, angleFor (a + (b - a) * k / 4.0), r + 3.6f, r + 6.0f, 0.9f); + } } g.setFont (fonts::label (10.0f, 0.02f)); for (auto value : scaleValues) { const auto angle = angleFor (value); - g.setColour (majorColour); - strokeRadial (g, geometry.centre, angle, r + 3.2f, r + 7.6f, 1.2f); + if (! ledRing) + { + g.setColour (majorColour); + strokeRadial (g, geometry.centre, angle, r + 3.2f, r + 7.6f, 1.2f); + } const auto text = scaleFormatter ? scaleFormatter (value) : juce::String (value); - const auto at = geometry.centre.getPointOnCircumference (r + 14.5f, angle); + const auto at = geometry.centre.getPointOnCircumference (ledRing ? r + 17.0f : r + 14.5f, angle); g.setColour (palette.silkscreenDim.withMultipliedAlpha (enabled ? 1.0f : 0.5f)); g.drawText (text, juce::Rectangle (34.0f, 12.0f).withCentre (at), juce::Justification::centred, false); } @@ -247,6 +291,21 @@ void Knob::paintReadout (juce::Graphics& g, const Geometry& geometry) { const auto& palette = theme.palette; const auto area = geometry.readout; + const auto live = isEnabled() && ! inactive; + const auto text = getTextFromValue (getValue()); + + if (palette.readout == Palette::Readout::backlit) + { + const auto level = ! isEnabled() ? 0.0f : (inactive ? 0.35f : 1.0f); + render::litWindow (g, area, palette.readoutBacklight, level); + if (entry.isVisible()) + return; + g.setColour (palette.readoutInk.withMultipliedAlpha (isEnabled() ? 0.92f : 0.45f)); + g.setFont (fonts::readout (14.5f, 0.02f)); + g.drawText (text, area.reduced (4.0f, 0.0f), juce::Justification::centred, false); + return; + } + g.setColour (palette.glass); g.fillRoundedRectangle (area, metrics::displayCorner); render::recess (g, area, metrics::displayCorner, 0.45f); @@ -254,14 +313,13 @@ void Knob::paintReadout (juce::Graphics& g, const Geometry& geometry) if (entry.isVisible()) return; - const auto live = isEnabled() && ! inactive; auto colour = palette.accent; if (! isEnabled()) colour = palette.silkscreenDim.withMultipliedBrightness (0.45f); else if (inactive) colour = palette.accent.withMultipliedSaturation (0.25f).withMultipliedBrightness (0.62f); - valueGlow.draw (g, getTextFromValue (getValue()), fonts::readout (14.5f, 0.02f), area.reduced (4.0f, 0.0f), + valueGlow.draw (g, text, fonts::readout (14.5f, 0.02f), area.reduced (4.0f, 0.0f), juce::Justification::centred, colour, 2.2f, live ? 0.85f : 0.35f); } diff --git a/libs/affine-ui/affine_ui/components/affine_Knob.h b/libs/affine-ui/affine_ui/components/affine_Knob.h index 18b1914..e27ce15 100644 --- a/libs/affine-ui/affine_ui/components/affine_Knob.h +++ b/libs/affine-ui/affine_ui/components/affine_Knob.h @@ -40,6 +40,9 @@ class Knob : public juce::Slider /** A lamp beside the label that reports whether the control currently acts on the sound. */ void setShowsActivityLamp (bool shouldShow); + /** A ring of LEDs around the knob, lit up to the current value, instead of printed ticks. */ + void setLedRing (bool shouldShow); + juce::Point getPreferredSize() const; /** Preferred bounds, in the parent, that put the knob's axis at `knobCentre`. */ juce::Rectangle getBoundsForCentre (juce::Point knobCentre) const; @@ -85,7 +88,7 @@ class Knob : public juce::Slider std::vector scaleValues; std::function scaleFormatter; double keyboardStep = 0.0; - bool inactive = false, activityLamp = false; + bool inactive = false, activityLamp = false, ledRing = false; juce::TextEditor entry; juce::Point lastDrag; diff --git a/libs/affine-ui/affine_ui/components/affine_LadderMeter.cpp b/libs/affine-ui/affine_ui/components/affine_LadderMeter.cpp index 686d4c8..f0a30f5 100644 --- a/libs/affine-ui/affine_ui/components/affine_LadderMeter.cpp +++ b/libs/affine-ui/affine_ui/components/affine_LadderMeter.cpp @@ -12,6 +12,12 @@ void LadderMeter::setTheme (const Theme& t) repaint(); } +void LadderMeter::setOrientation (Orientation o) +{ + orientation = o; + repaint(); +} + void LadderMeter::setRange (float minimumDb, float maximumDb, int segmentCount) { minimum = minimumDb; @@ -60,9 +66,20 @@ constexpr float segmentGap = 2.0f; LadderMeter::Layout LadderMeter::getLayout() const { - // The clip cell sits apart at the top; the level segments fill the rest of the column. - auto inner = getLocalBounds().toFloat().reduced (4.0f, 5.0f); + // The clip cell sits apart at the far end; the level segments fill the rest. Layout layout; + if (orientation == Orientation::horizontal) + { + auto inner = getLocalBounds().toFloat().reduced (5.0f, 4.0f); + const auto cell = (inner.getWidth() - segmentGap * static_cast (segments)) / static_cast (segments + 1); + layout.clipCell = inner.removeFromRight (cell); + inner.removeFromRight (segmentGap * 2.0f); + layout.column = inner; + layout.segmentHeight = (inner.getWidth() - segmentGap * static_cast (segments - 1)) / static_cast (segments); + return layout; + } + + auto inner = getLocalBounds().toFloat().reduced (4.0f, 5.0f); const auto cell = (inner.getHeight() - segmentGap * static_cast (segments)) / static_cast (segments + 1); layout.clipCell = inner.removeFromTop (cell); inner.removeFromTop (segmentGap * 2.0f); @@ -99,7 +116,8 @@ void LadderMeter::paint (juce::Graphics& g) g.setGradientFill (diffuser); g.fillRoundedRectangle (segment, 1.2f); g.setColour (juce::Colours::white.withAlpha (0.35f * lit)); - g.fillRoundedRectangle (segment.withHeight (juce::jmin (1.2f, segment.getHeight() * 0.4f)).reduced (1.0f, 0.0f), 0.6f); + if (orientation == Orientation::vertical) + g.fillRoundedRectangle (segment.withHeight (juce::jmin (1.2f, segment.getHeight() * 0.4f)).reduced (1.0f, 0.0f), 0.6f); }; paintSegment (layout.clipCell, theme.palette.danger, clip ? 1.0f : 0.0f); @@ -107,9 +125,13 @@ void LadderMeter::paint (juce::Graphics& g) for (int i = 0; i < segments; ++i) { const auto lower = minimum + step * static_cast (i); - const auto y = layout.column.getBottom() - static_cast (i + 1) * layout.segmentHeight - static_cast (i) * segmentGap; const auto lit = live ? juce::jlimit (0.0f, 1.0f, (level - lower) / step) : 0.0f; - paintSegment ({ layout.column.getX(), y, layout.column.getWidth(), layout.segmentHeight }, colourFor (lower + step * 0.5f), lit); + const auto offset = static_cast (i) * (layout.segmentHeight + segmentGap); + const auto segment = orientation == Orientation::horizontal + ? juce::Rectangle (layout.column.getX() + offset, layout.column.getY(), layout.segmentHeight, layout.column.getHeight()) + : juce::Rectangle (layout.column.getX(), layout.column.getBottom() - offset - layout.segmentHeight, + layout.column.getWidth(), layout.segmentHeight); + paintSegment (segment, colourFor (lower + step * 0.5f), lit); } } diff --git a/libs/affine-ui/affine_ui/components/affine_LadderMeter.h b/libs/affine-ui/affine_ui/components/affine_LadderMeter.h index 9e84503..fa6e8e9 100644 --- a/libs/affine-ui/affine_ui/components/affine_LadderMeter.h +++ b/libs/affine-ui/affine_ui/components/affine_LadderMeter.h @@ -9,9 +9,13 @@ namespace affine class LadderMeter : public juce::Component { public: + enum class Orientation { vertical, horizontal }; + LadderMeter(); void setTheme (const Theme&); + /** Vertical columns rise from the bottom; horizontal bars run left to right with the clip cell at the right. */ + void setOrientation (Orientation); void setRange (float minimumDb, float maximumDb, int segmentCount); /** Level colours: segments at or above `hotDb` use attention, at or above `dangerDb` use danger. */ void setZones (float hotDb, float dangerDb); @@ -19,7 +23,8 @@ class LadderMeter : public juce::Component /** Lights the separate clip cell at the top of the column. */ void setClip (bool clipped); - /** In parent coordinates: the span from `maximum` (top) to `minimum` (bottom), for a printed scale. */ + /** In parent coordinates: the span covered by the level segments, for a printed scale. + Vertical meters run from `maximum` at the top; horizontal meters from `minimum` at the left. */ juce::Rectangle getScaleSpan() const; /** In parent coordinates: the centre of the clip cell. */ juce::Point getClipCentre() const; @@ -37,6 +42,7 @@ class LadderMeter : public juce::Component juce::Colour colourFor (float segmentDb) const; Theme theme; + Orientation orientation = Orientation::vertical; float minimum = -60.0f, maximum = 0.0f, hot = -12.0f, danger = -3.0f, level = -100.0f; int segments = 24; bool live = false, clip = false; diff --git a/libs/affine-ui/affine_ui/components/affine_Legends.cpp b/libs/affine-ui/affine_ui/components/affine_Legends.cpp new file mode 100644 index 0000000..a380476 --- /dev/null +++ b/libs/affine-ui/affine_ui/components/affine_Legends.cpp @@ -0,0 +1,233 @@ +namespace affine +{ +IlluminatedLegends::IlluminatedLegends() +{ + setBorderSize (juce::BorderSize (0)); +} + +void IlluminatedLegends::setTheme (const Theme& t) +{ + theme = t; + if (lit.isTransparent()) + lit = theme.palette.accent; + repaint(); +} + +void IlluminatedLegends::setLegends (const juce::StringArray& newLegends, std::function legendForText) +{ + legends = newLegends; + legendFor = std::move (legendForText); + repaint(); +} + +void IlluminatedLegends::setLitColour (juce::Colour colour) +{ + if (colour == lit) + return; + lit = colour; + repaint(); +} + +void IlluminatedLegends::paint (juce::Graphics& g) +{ + if (legends.isEmpty()) + return; + + const auto active = legendFor != nullptr ? legendFor (getText()) : -1; + const auto bounds = getLocalBounds().toFloat(); + const auto font = fonts::label (12.0f, 0.20f); + g.setFont (font); + + // Each legend takes its own width; the spare space separates them evenly. + std::vector widths; + auto total = 0.0f; + for (const auto& legend : legends) + { + juce::GlyphArrangement glyphs; + glyphs.addLineOfText (font, legend.toUpperCase(), 0.0f, 0.0f); + widths.push_back (glyphs.getBoundingBox (0, -1, true).getWidth()); + total += widths.back(); + } + const auto gap = juce::jmax (8.0f, (bounds.getWidth() - total) / static_cast (juce::jmax (1, legends.size() - 1))); + auto x = bounds.getX() + juce::jmax (0.0f, (bounds.getWidth() - total - gap * static_cast (legends.size() - 1)) * 0.5f); + + for (int i = 0; i < legends.size(); ++i) + { + const auto width = widths[static_cast (i)]; + const auto area = juce::Rectangle (x, bounds.getY(), width, bounds.getHeight()); + if (i == active) + { + render::halo (g, area.getCentre(), width * 0.75f + 8.0f, lit, 0.30f); + g.setColour (lit.interpolatedWith (juce::Colours::white, 0.35f)); + } + else + { + // Unlit legends are only just visible through the smoked glass. + g.setColour (juce::Colour (0xff3a4047)); + } + g.drawText (legends[i].toUpperCase(), area.expanded (4.0f, 0.0f), juce::Justification::centred, false); + x += width + gap; + } +} + +//============================================================================== +TuningDial::TuningDial() +{ + setOpaque (false); + setInterceptsMouseClicks (false, false); +} + +TuningDial::~TuningDial() { stopTimer(); } + +void TuningDial::setTheme (const Theme& t) +{ + theme = t; + scaleLayer = {}; + repaint(); +} + +void TuningDial::setRange (float minimumHz, float maximumHz) +{ + minimum = minimumHz; + maximum = maximumHz; + scaleLayer = {}; + repaint(); +} + +void TuningDial::setPointerColour (juce::Colour c) +{ + pointer = c; + repaint(); +} + +float TuningDial::positionFor (float hz) const +{ + return juce::jlimit (0.0f, 1.0f, std::log (hz / minimum) / std::log (maximum / minimum)); +} + +void TuningDial::setFrequency (float hz, bool live) +{ + const auto p = live && hz > 0.0f ? positionFor (hz) : 0.0f; + const auto l = live && hz > 0.0f ? 1.0f : 0.0f; + if (juce::approximatelyEqual (p, target) && juce::approximatelyEqual (l, lampTarget)) + return; + target = p; + lampTarget = l; + if (! isTimerRunning()) + { + lastTick = juce::Time::getMillisecondCounterHiRes(); + startTimerHz (60); + } +} + +void TuningDial::settle() +{ + stopTimer(); + position = target; + lamp = lampTarget; + repaint(); +} + +void TuningDial::timerCallback() +{ + const auto now = juce::Time::getMillisecondCounterHiRes(); + const auto dt = static_cast (juce::jlimit (0.0, 0.1, (now - lastTick) * 0.001)); + lastTick = now; + // The dial cord drags the pointer along: an exponential glide, about 120 ms. + position += (target - position) * (1.0f - std::exp (-dt / 0.06f)); + lamp += (lampTarget - lamp) * (1.0f - std::exp (-dt / 0.15f)); + repaint(); + if (std::abs (target - position) < 0.0005f && std::abs (lampTarget - lamp) < 0.002f) + { + position = target; + lamp = lampTarget; + stopTimer(); + } +} + +void TuningDial::resized() { scaleLayer = {}; } + +void TuningDial::renderScale (float scale) +{ + const auto w = juce::roundToInt (static_cast (getWidth()) * scale); + const auto h = juce::roundToInt (static_cast (getHeight()) * scale); + scaleLayer = juce::Image (juce::Image::SingleChannel, juce::jmax (1, w), juce::jmax (1, h), true); + scaleLayerScale = scale; + juce::Graphics g (scaleLayer); + g.addTransform (juce::AffineTransform::scale (scale)); + g.setColour (juce::Colours::white); + + const auto area = getLocalBounds().toFloat().reduced (18.0f, 10.0f); + const auto baseline = area.getY() + area.getHeight() * 0.50f; + const auto xFor = [&] (float hz) { return area.getX() + area.getWidth() * positionFor (hz); }; + + g.fillRect (area.getX(), baseline, area.getWidth(), 1.2f); + const float decades[] { 10.0f, 100.0f, 1000.0f, 10000.0f }; + for (auto decade : decades) + { + for (int step = 1; step < 10; ++step) + { + const auto hz = decade * static_cast (step); + if (hz < minimum || hz > maximum) + continue; + const auto major = step == 1 || step == 2 || step == 5; + const auto x = xFor (hz); + g.fillRect (x - 0.6f, baseline - (major ? 11.0f : 6.0f), 1.2f, major ? 11.0f : 6.0f); + if (major) + { + const auto text = hz >= 1000.0f ? juce::String (juce::roundToInt (hz / 1000.0f)) + "k" : juce::String (juce::roundToInt (hz)); + g.setFont (fonts::label (12.5f, 0.04f)); + g.drawText (text, juce::Rectangle (40.0f, 14.0f).withCentre ({ x, baseline - 21.0f }), juce::Justification::centred, false); + } + } + } + + // A musical row under the frequencies: the A of every octave. + g.setFont (fonts::label (10.5f, 0.10f)); + for (int octave = 0; octave <= 9; ++octave) + { + const auto hz = 27.5f * static_cast (1 << octave); + if (hz < minimum || hz > maximum) + continue; + const auto x = xFor (hz); + g.fillEllipse (juce::Rectangle (3.0f, 3.0f).withCentre ({ x, baseline + 7.0f })); + g.drawText ("A" + juce::String (octave), juce::Rectangle (30.0f, 12.0f).withCentre ({ x, baseline + 17.0f }), + juce::Justification::centred, false); + } +} + +void TuningDial::paint (juce::Graphics& g) +{ + const auto scale = juce::jmax (0.5f, g.getInternalContext().getPhysicalPixelScaleFactor()); + const auto bounds = getLocalBounds().toFloat(); + if (! scaleLayer.isValid() || ! juce::approximatelyEqual (scaleLayerScale, scale)) + renderScale (scale); + + // Dial glass: smoked, lit from behind by the dial lamps. + g.setColour (juce::Colour (0xff020304)); + g.fillRoundedRectangle (bounds, 4.0f); + const auto accent = theme.palette.accent; + const auto glow = 0.25f + 0.75f * lamp; + juce::ColourGradient backlight (accent.withAlpha (0.22f * glow), bounds.getCentreX(), bounds.getCentreY(), + accent.withAlpha (0.0f), bounds.getX(), bounds.getY(), true); + g.setGradientFill (backlight); + g.fillRoundedRectangle (bounds, 4.0f); + + const auto toLogical = juce::AffineTransform::scale (1.0f / scale); + g.setColour (accent.interpolatedWith (juce::Colours::white, 0.35f).withMultipliedAlpha (0.55f + 0.45f * glow)); + g.drawImageTransformed (scaleLayer, toLogical, true); + + // Pointer. + const auto area = bounds.reduced (18.0f, 10.0f); + const auto x = area.getX() + area.getWidth() * position; + render::halo (g, { x, bounds.getCentreY() }, 16.0f, pointer, 0.35f * (0.3f + 0.7f * lamp)); + g.setColour (pointer.withMultipliedAlpha (0.45f + 0.55f * lamp)); + g.fillRect (juce::Rectangle (x - 1.2f, bounds.getY() + 5.0f, 2.4f, bounds.getHeight() - 10.0f)); + + // Glass reflection. + juce::ColourGradient sheen (juce::Colours::white.withAlpha (0.07f), bounds.getX(), bounds.getY(), + juce::Colours::white.withAlpha (0.0f), bounds.getX(), bounds.getCentreY(), false); + g.setGradientFill (sheen); + g.fillRoundedRectangle (bounds.withHeight (bounds.getHeight() * 0.5f), 4.0f); +} +} // namespace affine diff --git a/libs/affine-ui/affine_ui/components/affine_Legends.h b/libs/affine-ui/affine_ui/components/affine_Legends.h new file mode 100644 index 0000000..179feb2 --- /dev/null +++ b/libs/affine-ui/affine_ui/components/affine_Legends.h @@ -0,0 +1,65 @@ +#pragma once + +namespace affine +{ +/** + Legends printed behind a glass front; the one that describes the current + state lights up. The label's text stays the accessible state; the mapping + chooses which legend it lights. +*/ +class IlluminatedLegends : public juce::Label +{ +public: + IlluminatedLegends(); + + void setTheme (const Theme&); + void setLegends (const juce::StringArray& legends, std::function legendForText); + /** Colour of the lit legend, e.g. attention while auditioning. */ + void setLitColour (juce::Colour); + + void paint (juce::Graphics&) override; + +private: + Theme theme; + juce::StringArray legends; + std::function legendFor; + juce::Colour lit; +}; + +/** + A radio-style tuning dial: a backlit logarithmic frequency scale with a + pointer that glides to the reading and parks at the left stop when there is none. +*/ +class TuningDial : public juce::Component, private juce::Timer +{ +public: + TuningDial(); + ~TuningDial() override; + + void setTheme (const Theme&); + void setRange (float minimumHz, float maximumHz); + /** Pointer colour; the scale glows in the theme accent. */ + void setPointerColour (juce::Colour); + void setFrequency (float hz, bool live); + /** Skips the remaining glide, e.g. before a still capture. */ + void settle(); + + void paint (juce::Graphics&) override; + void resized() override; + +private: + void timerCallback() override; + float positionFor (float hz) const; + void renderScale (float scale); + + Theme theme; + juce::Colour pointer { 0xffff5a3c }; + float minimum = 20.0f, maximum = 20000.0f; + float target = 0.0f, position = 0.0f, lamp = 0.0f, lampTarget = 0.0f; + double lastTick = 0.0; + juce::Image scaleLayer; + float scaleLayerScale = 0.0f; + + JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (TuningDial) +}; +} // namespace affine diff --git a/libs/affine-ui/affine_ui/components/affine_NeedleMeter.cpp b/libs/affine-ui/affine_ui/components/affine_NeedleMeter.cpp index f3866b0..ae435c6 100644 --- a/libs/affine-ui/affine_ui/components/affine_NeedleMeter.cpp +++ b/libs/affine-ui/affine_ui/components/affine_NeedleMeter.cpp @@ -3,7 +3,6 @@ namespace affine namespace { constexpr float housingInset = 6.0f, bezel = 11.0f, sweep = 0.78f; // sweep: half-angle in radians -const juce::Colour ink { 0xff1d1b18 }; } // namespace NeedleMeter::NeedleMeter() @@ -24,6 +23,7 @@ void NeedleMeter::setTheme (const Theme& t) void NeedleMeter::setScale (Scale s) { scale = std::move (s); + position = target = scale.restPosition; face = {}; repaint(); } @@ -39,14 +39,27 @@ void NeedleMeter::setCaption (const juce::String& caption) void NeedleMeter::setBacklight (juce::Colour c) { - backlight = c; + style.backlight = c; face = {}; repaint(); } +void NeedleMeter::setFace (const Face& f) +{ + style = f; + face = {}; + repaint(); +} + +void NeedleMeter::setBallistics (float frequencyHz, float damping) +{ + naturalFrequency = juce::jmax (0.1f, frequencyHz); + dampingRatio = juce::jmax (0.05f, damping); +} + void NeedleMeter::setReading (float value, bool live) { - const auto p = live && scale.toPosition ? juce::jlimit (0.0f, 1.0f, scale.toPosition (value)) : 0.0f; + const auto p = live && scale.toPosition ? juce::jlimit (0.0f, 1.0f, scale.toPosition (value)) : scale.restPosition; lampTarget = live ? 1.0f : 0.0f; if (juce::approximatelyEqual (p, target) && juce::approximatelyEqual (lamp, lampTarget) && juce::approximatelyEqual (position, target)) @@ -74,9 +87,9 @@ void NeedleMeter::timerCallback() const auto dt = static_cast (juce::jlimit (0.0, 0.1, (now - lastTick) * 0.001)); lastTick = now; - // Moving-coil ballistics: a lightly under-damped spring, about 300 ms to settle. - constexpr float omega = juce::MathConstants::twoPi * 2.1f, zeta = 0.74f; - constexpr int steps = 4; + // Moving-coil ballistics: a lightly under-damped spring. + const auto omega = juce::MathConstants::twoPi * naturalFrequency, zeta = dampingRatio; + constexpr int steps = 8; const auto h = dt / static_cast (steps); for (int i = 0; i < steps; ++i) { @@ -105,8 +118,9 @@ juce::Rectangle NeedleMeter::faceBounds() const juce::Point NeedleMeter::pivot() const { + // Hang the arc a quarter of the way down the card, whatever its proportions. const auto f = faceBounds(); - return { f.getCentreX(), f.getBottom() + f.getHeight() * 0.14f }; + return { f.getCentreX(), f.getY() + f.getHeight() * 0.27f + arcRadius() }; } float NeedleMeter::arcRadius() const @@ -132,14 +146,22 @@ void NeedleMeter::renderFace (float physicalScale) const auto centre = pivot(); const auto radius = arcRadius(); + // Printing is sized for a 180 px card and shrinks, within legibility, on smaller meters. + const auto k = juce::jlimit (0.60f, 1.0f, area.getHeight() / 180.0f); // Backlit card: the lamp sits low behind the scale, so light falls off upwards and outwards. + const auto backlight = style.backlight; + const auto ink = style.ink; juce::ColourGradient light (backlight.brighter (0.12f), area.getCentreX(), area.getBottom() - area.getHeight() * 0.10f, - backlight.darker (1.1f), area.getX() - area.getWidth() * 0.12f, area.getY() - area.getHeight() * 0.25f, true); + backlight.darker (style.vignette), area.getX() - area.getWidth() * 0.12f, area.getY() - area.getHeight() * 0.25f, true); light.addColour (0.30, backlight); - light.addColour (0.62, backlight.darker (0.30f)); + light.addColour (0.62, backlight.darker (style.vignette * 0.3f)); g.setGradientFill (light); g.fillRect (area); + if (style.twinLamps) + for (auto fraction : { 0.24f, 0.76f }) + render::halo (g, { area.getX() + area.getWidth() * fraction, area.getBottom() - area.getHeight() * 0.06f }, + area.getWidth() * 0.34f, backlight.brighter (0.35f), 0.55f); // Paper fibre. for (int i = 0; i < 1400; ++i) @@ -157,7 +179,18 @@ void NeedleMeter::renderFace (float physicalScale) return p; }; + // Red zone: a band just inside the scale arc. + if (style.zoneFrom <= 1.0f) + { + juce::Path zone; + zone.addCentredArc (centre.x, centre.y, radius - 4.0f, radius - 4.0f, 0.0f, + angleFor (juce::jlimit (0.0f, 1.0f, style.zoneFrom)), sweep, true); + g.setColour (style.zone); + g.strokePath (zone, juce::PathStrokeType (6.5f * k, juce::PathStrokeType::curved, juce::PathStrokeType::butt)); + } + // Anti-parallax mirror band. + if (style.mirror) { juce::ColourGradient mirror (juce::Colour (0xffe4e7ea), centre.x - radius, centre.y - radius, juce::Colour (0xff5f666d), centre.x + radius * 0.7f, centre.y - radius * 0.3f, false); @@ -187,25 +220,33 @@ void NeedleMeter::renderFace (float physicalScale) { g.setColour (ink.withAlpha (0.85f)); for (auto v : scale.minors) - tick (v, 5.0f, 1.0f); + tick (v, 5.0f * k, 1.0f); g.setColour (ink); - g.setFont (fonts::label (15.5f, 0.0f)); + g.setFont (fonts::label (juce::jmax (10.5f, 15.5f * k), 0.0f)); for (auto v : scale.majors) { - tick (v, 9.5f, 1.8f); + tick (v, 9.5f * k, 1.8f); const auto a = angleFor (juce::jlimit (0.0f, 1.0f, scale.toPosition (v))); - const auto at = centre.getPointOnCircumference (radius + 19.0f, a); + const auto at = centre.getPointOnCircumference (radius + 19.0f * k, a); g.drawText (scale.format ? scale.format (v) : juce::String (v), juce::Rectangle (40.0f, 18.0f).withCentre (at), juce::Justification::centred, false); } } + if (style.brand.isNotEmpty()) + { + g.setColour (ink.withAlpha (0.75f)); + g.setFont (fonts::label (10.0f, 0.3f)); + g.drawText (style.brand, juce::Rectangle (60.0f, 12.0f).withPosition (area.getX() + 12.0f, area.getBottom() - 20.0f), + juce::Justification::centredLeft, false); + } + g.setColour (ink); - g.setFont (fonts::wordmark (12.0f, 0.22f)); + g.setFont (fonts::wordmark (juce::jmax (9.0f, 12.0f * k), 0.22f)); g.drawText (scale.unit, juce::Rectangle (area.getWidth() * 0.5f, 16.0f).withCentre ({ area.getCentreX(), area.getY() + area.getHeight() * 0.58f }), juce::Justification::centred, false); g.setColour (ink.withAlpha (0.8f)); - g.setFont (fonts::label (11.0f, 0.26f)); + g.setFont (fonts::label (juce::jmax (9.0f, 11.0f * k), 0.26f)); g.drawText (scale.caption.toUpperCase(), juce::Rectangle (area.getWidth(), 14.0f).withCentre ({ area.getCentreX(), area.getY() + area.getHeight() * 0.71f }), juce::Justification::centred, false); @@ -228,9 +269,16 @@ void NeedleMeter::paint (juce::Graphics& g) const auto housing = getLocalBounds().toFloat().reduced (housingInset); const auto area = faceBounds(); - // Housing: a black moulded bezel standing slightly proud of the faceplate. - render::softShadow (g, housing, 9.0f, 9.0f, { 0.0f, 4.0f }, 0.6f); + if (style.bezel == Face::Bezel::flush) + { + // Behind a glass front the meter shows only a thin black frame. + g.setColour (juce::Colour (0xff020303)); + g.fillRoundedRectangle (area.expanded (3.0f), 4.0f); + } + else { + // Housing: a black moulded bezel standing slightly proud of the faceplate. + render::softShadow (g, housing, 9.0f, 9.0f, { 0.0f, 4.0f }, 0.6f); juce::ColourGradient body (juce::Colour (0xff30343a), housing.getX(), housing.getY(), juce::Colour (0xff0a0b0d), housing.getX(), housing.getBottom(), false); body.addColour (0.08, juce::Colour (0xff24272c)); @@ -239,12 +287,28 @@ void NeedleMeter::paint (juce::Graphics& g) g.setColour (juce::Colours::white.withAlpha (0.13f)); g.drawRoundedRectangle (housing.reduced (0.6f), 9.0f, 1.0f); - // Inner chamfer down to the glass: lit from above, so its lower edge catches light. const auto chamfer = area.expanded (4.0f); - juce::ColourGradient bevel (juce::Colour (0xff050506), chamfer.getX(), chamfer.getY(), - juce::Colour (0xff3a3e44), chamfer.getX(), chamfer.getBottom(), false); - g.setGradientFill (bevel); - g.fillRoundedRectangle (chamfer, 4.0f); + if (style.bezel == Face::Bezel::chrome) + { + // Polished ring: the ceiling reflects in its upper half, the floor in its lower half. + juce::ColourGradient chrome (juce::Colour (0xfff4f6f8), chamfer.getX(), chamfer.getY(), + juce::Colour (0xff3a3d42), chamfer.getX(), chamfer.getBottom(), false); + chrome.addColour (0.35, juce::Colour (0xffb9bec4)); + chrome.addColour (0.52, juce::Colour (0xff5d6167)); + chrome.addColour (0.80, juce::Colour (0xffd9dde1)); + g.setGradientFill (chrome); + g.fillRoundedRectangle (chamfer.expanded (1.5f), 5.5f); + g.setColour (juce::Colours::black.withAlpha (0.7f)); + g.fillRoundedRectangle (area.expanded (1.5f), 3.0f); + } + else + { + // Inner chamfer down to the glass: lit from above, so its lower edge catches light. + juce::ColourGradient bevel (juce::Colour (0xff050506), chamfer.getX(), chamfer.getY(), + juce::Colour (0xff3a3e44), chamfer.getX(), chamfer.getBottom(), false); + g.setGradientFill (bevel); + g.fillRoundedRectangle (chamfer, 4.0f); + } } if (! face.isValid() || ! juce::approximatelyEqual (faceScale, physicalScale)) diff --git a/libs/affine-ui/affine_ui/components/affine_NeedleMeter.h b/libs/affine-ui/affine_ui/components/affine_NeedleMeter.h index 7eebc74..84a2491 100644 --- a/libs/affine-ui/affine_ui/components/affine_NeedleMeter.h +++ b/libs/affine-ui/affine_ui/components/affine_NeedleMeter.h @@ -18,6 +18,28 @@ class NeedleMeter : public juce::Component, private juce::Timer std::vector majors, minors; std::function format; juce::String unit, caption; + float restPosition = 0.0f; // where the needle rests without a reading: 0.5 for centre-zero meters + }; + + /** The printed card and the housing around it. */ + struct Face + { + enum class Bezel + { + moulded, // black moulded housing standing proud of the plate + chrome, // black housing with a polished chrome ring, as on vintage panel meters + flush // behind a glass front: only a thin black frame + }; + + juce::Colour backlight { 0xfff2e4c4 }; // the lamp behind the card + juce::Colour ink { 0xff1d1b18 }; // printing on the card + juce::Colour zone { 0xffc23b2e }; // colour of the red zone + float zoneFrom = 2.0f; // scale position where the red zone begins; above 1 for none + bool mirror = true; // anti-parallax mirror band + float vignette = 1.1f; // how much darker the card's corners are than its lit centre + bool twinLamps = false; // two bulbs low behind the card instead of one + Bezel bezel = Bezel::moulded; + juce::String brand; // small print on the card, e.g. "VU" }; NeedleMeter(); @@ -26,10 +48,14 @@ class NeedleMeter : public juce::Component, private juce::Timer void setTheme (const Theme&); void setScale (Scale); void setCaption (const juce::String&); + void setFace (const Face&); /** Face backlight colour: the lamp behind the printed scale. */ void setBacklight (juce::Colour); + /** Movement: natural frequency in Hz and damping ratio. The default is a VU-like 2.1 Hz, 0.74. */ + void setBallistics (float frequencyHz, float damping); + /** Sets the reading. When `live` is false the needle rests and the lamp dims. */ void setReading (float value, bool live); @@ -51,7 +77,8 @@ class NeedleMeter : public juce::Component, private juce::Timer Theme theme; Scale scale; - juce::Colour backlight { 0xfff3ead6 }; + Face style; + float naturalFrequency = 2.1f, dampingRatio = 0.74f; juce::Image face; float faceScale = 0.0f; float target = 0.0f, position = 0.0f, velocity = 0.0f, lamp = 0.0f, lampTarget = 0.0f; diff --git a/libs/affine-ui/affine_ui/components/affine_Nixie.cpp b/libs/affine-ui/affine_ui/components/affine_Nixie.cpp index ca78495..7a33a6e 100644 --- a/libs/affine-ui/affine_ui/components/affine_Nixie.cpp +++ b/libs/affine-ui/affine_ui/components/affine_Nixie.cpp @@ -11,42 +11,32 @@ juce::Rectangle glyphAreaIn (juce::Rectangle tube) .withTrimmedBottom (tube.getHeight() * 0.19f); } -int spriteIndex (juce::juce_wchar c) -{ - if (c >= '0' && c <= '9') - return static_cast (c - '0'); - if (c == '-') - return 10; - return -1; -} - -juce::Path rawGlyph (juce::juce_wchar c) +juce::Path rawGlyph (juce::juce_wchar c, bool segments) { juce::GlyphArrangement glyphs; - glyphs.addLineOfText (fonts::nixie (100.0f), juce::String::charToString (c), 0.0f, 0.0f); + glyphs.addLineOfText (segments ? fonts::segment (100.0f) : fonts::nixie (100.0f), juce::String::charToString (c), 0.0f, 0.0f); juce::Path path; glyphs.createPath (path); return path; } -// Numeral cathodes share one height and baseline; each is centred in the tube. -juce::Path cathode (juce::juce_wchar c, juce::Rectangle area) +// Numeral cathodes share one height and baseline and are centred in the tube. +// Segment tubes place every character in the same fourteen-segment cell. +juce::Path cathode (juce::juce_wchar c, juce::Rectangle area, bool segments) { - static const auto reference = rawGlyph ('0').getBounds(); - auto path = rawGlyph (c); - const auto bounds = path.getBounds(); + static const auto numeralCell = rawGlyph ('0', false).getBounds(); + static const auto segmentCell = rawGlyph ('~', true).getBounds(); + const auto& reference = segments ? segmentCell : numeralCell; + auto path = rawGlyph (c, segments); + const auto centreX = segments ? reference.getCentreX() : path.getBounds().getCentreX(); const auto sy = area.getHeight() / reference.getHeight(); const auto sx = juce::jmin (sy, area.getWidth() / reference.getWidth()); - path.applyTransform (juce::AffineTransform::translation (-bounds.getCentreX(), -reference.getY()) + path.applyTransform (juce::AffineTransform::translation (-centreX, -reference.getY()) .scaled (sx, sy) .translated (area.getCentreX(), area.getY())); return path; } -void drawGlyph (juce::Graphics& g, juce::juce_wchar c, juce::Rectangle area) -{ - g.fillPath (cathode (c, area)); -} } // namespace NixieDisplay::NixieDisplay() @@ -65,7 +55,15 @@ void NixieDisplay::setNumTubes (int n) { tubes = juce::jmax (1, n); glassLayer = {}; - sprites = {}; + sprites.clear(); + repaint(); +} + +void NixieDisplay::setCharacters (Characters set) +{ + characters = set; + glassLayer = {}; + sprites.clear(); repaint(); } @@ -89,7 +87,7 @@ void NixieDisplay::setIntensity (float i) void NixieDisplay::resized() { glassLayer = {}; - sprites = {}; + sprites.clear(); } juce::Rectangle NixieDisplay::tubeBounds (int index) const @@ -112,11 +110,13 @@ std::vector NixieDisplay::layoutCells() const } cells.push_back ({ c, false }); } - // Right-align into the tubes, dropping anything that does not fit. + // Numbers right-align like a counter; words left-align. Anything that does not fit is dropped. std::vector placed (static_cast (tubes)); const auto count = juce::jmin (static_cast (cells.size()), tubes); + const auto first = characters == Characters::numerals ? tubes - count : 0; + const auto skip = characters == Characters::numerals ? cells.size() - static_cast (count) : 0; for (int i = 0; i < count; ++i) - placed[static_cast (tubes - count + i)] = cells[cells.size() - static_cast (count) + static_cast (i)]; + placed[static_cast (first + i)] = cells[skip + static_cast (i)]; return placed; } @@ -143,10 +143,11 @@ void NixieDisplay::renderTubes (float scale) g.setGradientFill (glass); g.fillPath (envelope); - // Ghost cathodes: every numeral, stacked, faintly visible through the mesh. - g.setColour (juce::Colour (0xff8a705a).withAlpha (0.09f)); - for (auto c : juce::String ("0123456789")) - drawGlyph (g, c, glyphs); + // Ghost cathodes: every electrode, stacked, faintly visible through the mesh. + const auto segments = characters == Characters::alphanumeric; + g.setColour (juce::Colour (0xff8a705a).withAlpha (segments ? 0.16f : 0.09f)); + for (auto c : juce::String (segments ? "~" : "0123456789")) + g.fillPath (cathode (c, glyphs, segments)); const auto dot = juce::Rectangle (3.0f, 3.0f).withCentre ({ glyphs.getRight() + tube.getWidth() * 0.05f, glyphs.getBottom() - 2.0f }); g.fillEllipse (dot); @@ -207,28 +208,33 @@ void NixieDisplay::renderFront (float scale) } } -void NixieDisplay::renderSprites (float scale, juce::Rectangle glyphArea) +const NixieDisplay::Sprite& NixieDisplay::spriteFor (juce::juce_wchar c, float scale, juce::Rectangle glyphArea) { - spriteScale = scale; - spriteArea = glyphArea.withZeroOrigin(); - spriteMargin = juce::roundToInt (8.0f * scale) + 2; - const auto w = juce::roundToInt (glyphArea.getWidth() * scale) + spriteMargin * 2; - const auto h = juce::roundToInt (glyphArea.getHeight() * scale) + spriteMargin * 2; - const juce::String chars ("0123456789-"); - - for (int i = 0; i < chars.length(); ++i) + if (! juce::approximatelyEqual (spriteScale, scale)) { - auto& sprite = sprites[static_cast (i)]; - sprite.crisp = juce::Image (juce::Image::SingleChannel, w, h, true); - { - juce::Graphics g (sprite.crisp); - g.addTransform (juce::AffineTransform::scale (scale).translated (static_cast (spriteMargin), static_cast (spriteMargin))); - g.setColour (juce::Colours::white); - drawGlyph (g, chars[i], spriteArea); - } - sprite.glow = sprite.crisp.createCopy(); - render::blurAlpha (sprite.glow, 4.5f * scale); + sprites.clear(); + spriteScale = scale; + spriteMargin = juce::roundToInt (8.0f * scale) + 2; + } + + auto found = sprites.find (c); + if (found != sprites.end()) + return found->second; + + const auto area = glyphArea.withZeroOrigin(); + const auto w = juce::roundToInt (area.getWidth() * scale) + spriteMargin * 2; + const auto h = juce::roundToInt (area.getHeight() * scale) + spriteMargin * 2; + Sprite sprite; + sprite.crisp = juce::Image (juce::Image::SingleChannel, w, h, true); + { + juce::Graphics g (sprite.crisp); + g.addTransform (juce::AffineTransform::scale (scale).translated (static_cast (spriteMargin), static_cast (spriteMargin))); + g.setColour (juce::Colours::white); + g.fillPath (cathode (c, area, characters == Characters::alphanumeric)); } + sprite.glow = sprite.crisp.createCopy(); + render::blurAlpha (sprite.glow, 4.5f * scale); + return sprites.emplace (c, std::move (sprite)).first->second; } void NixieDisplay::paint (juce::Graphics& g) @@ -239,9 +245,6 @@ void NixieDisplay::paint (juce::Graphics& g) renderTubes (scale); renderFront (scale); } - const auto firstGlyphs = glyphAreaIn (tubeBounds (0)); - if (! sprites[0].crisp.isValid() || ! juce::approximatelyEqual (spriteScale, scale)) - renderSprites (scale, firstGlyphs); g.drawImageTransformed (glassLayer, juce::AffineTransform::scale (1.0f / scale)); @@ -254,11 +257,11 @@ void NixieDisplay::paint (juce::Graphics& g) const auto tube = tubeBounds (i); const auto glyphs = glyphAreaIn (tube); const auto& cell = cells[static_cast (i)]; - const auto index = spriteIndex (cell.digit); + const auto shows = cell.digit != ' ' && cell.digit != 0; - if (index >= 0 && intensity > 0.0f) + if (shows && intensity > 0.0f) { - const auto& sprite = sprites[static_cast (index)]; + const auto& sprite = spriteFor (cell.digit, scale, glyphs); const auto transform = juce::AffineTransform::translation (static_cast (-spriteMargin), static_cast (-spriteMargin)) .scaled (1.0f / scale) .translated (glyphs.getX(), glyphs.getY()); diff --git a/libs/affine-ui/affine_ui/components/affine_Nixie.h b/libs/affine-ui/affine_ui/components/affine_Nixie.h index d9ddc45..35b7d5c 100644 --- a/libs/affine-ui/affine_ui/components/affine_Nixie.h +++ b/libs/affine-ui/affine_ui/components/affine_Nixie.h @@ -7,15 +7,19 @@ namespace affine the ten numeral cathodes stacked behind a honeycomb anode; unlit cathodes stay faintly visible, exactly as in the real part. - Text is right-aligned into the tubes. Supported characters: 0-9, '.', '-', ' '. - A '.' lights the decimal cathode of the preceding tube. + Numeral tubes right-align their text and support 0-9, '.', '-' and ' '; + a '.' lights the decimal cathode of the preceding tube. Alphanumeric tubes + carry fourteen neon segments, as in the B-7971, and left-align their text. */ class NixieDisplay : public juce::Component { public: + enum class Characters { numerals, alphanumeric }; + NixieDisplay(); void setTheme (const Theme&); + void setCharacters (Characters); void setNumTubes (int); void setText (const juce::String&); /** 0 = tubes dark (no reading), 1 = fully lit. */ @@ -37,7 +41,6 @@ class NixieDisplay : public juce::Component juce::Rectangle tubeBounds (int index) const; void renderTubes (float scale); void renderFront (float scale); - void renderSprites (float scale, juce::Rectangle glyphArea); Theme theme; int tubes = 5; @@ -51,10 +54,12 @@ class NixieDisplay : public juce::Component { juce::Image crisp, glow; }; - std::array sprites; // 0-9 and '-' + const Sprite& spriteFor (juce::juce_wchar, float scale, juce::Rectangle glyphArea); + + Characters characters = Characters::numerals; + std::map sprites; float spriteScale = 0.0f; int spriteMargin = 0; - juce::Rectangle spriteArea; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (NixieDisplay) }; diff --git a/libs/affine-ui/affine_ui/render/affine_KnobRenderer.cpp b/libs/affine-ui/affine_ui/render/affine_KnobRenderer.cpp index 434610d..c58c2ec 100644 --- a/libs/affine-ui/affine_ui/render/affine_KnobRenderer.cpp +++ b/libs/affine-ui/affine_ui/render/affine_KnobRenderer.cpp @@ -4,18 +4,26 @@ namespace { using namespace shading; -// Knob profile as fractions of the radius, from the centre outwards. -constexpr float chamferEdge = 0.636f, rimStart = 0.950f; - -enum class Material { cap, chamfer, grip, skirt }; +enum class Zone { cap, chamfer, grip, skirt }; struct Surface { V3 normal; - Material material; + Zone zone; float occlusion; }; +// Radii of the knob's zones as fractions of its radius. +struct Profile +{ + float cap, chamfer, grip, rim, dome; +}; + +Profile profileFor (const KnobFinish& finish) noexcept +{ + return { finish.capRatio, finish.capRatio + 0.036f, finish.gripRatio, 0.950f, finish.capDome }; +} + const V3 keyLight = V3 { -0.30f, -0.78f, 0.62f }.normalised(); const V3 fillLight = V3 { 0.55f, 0.35f, 0.76f }.normalised(); const V3 softbox = V3 { -0.16f, -0.58f, 0.80f }.normalised(); @@ -30,104 +38,118 @@ float environment (V3 r) noexcept return 0.030f + 1.35f * box + 0.20f * sky * sky; } -Surface surfaceAt (float r, float phi, float gripAngle, int ridges, bool knurled) noexcept +Surface surfaceAt (float r, float phi, float gripAngle, int ridges, float ridgeDepth, bool shaped, const Profile& p) noexcept { const auto c = std::cos (phi), s = std::sin (phi); const V3 radial { c, s, 0.0f }, tangent { -s, c, 0.0f }, up { 0.0f, 0.0f, 1.0f }; float slope = 0.0f, occlusion = 1.0f; - Material material = Material::cap; + Zone zone = Zone::cap; - if (r < KnobRenderer::capRadius) + if (r < p.cap) { - // A barely domed face keeps the reflection alive across the cap. - slope = 0.06f * r / KnobRenderer::capRadius; + // A slightly domed face keeps the reflection alive across the cap. + slope = p.dome * r / p.cap; } - else if (r < chamferEdge) + else if (r < p.chamfer) { - material = Material::chamfer; - slope = mix (0.55f, 0.95f, (r - KnobRenderer::capRadius) / (chamferEdge - KnobRenderer::capRadius)); + zone = Zone::chamfer; + slope = mix (0.55f, 0.95f, (r - p.cap) / (p.chamfer - p.cap)); } - else if (r < KnobRenderer::skirtRadius) + else if (r < p.grip) { - material = Material::grip; + zone = Zone::grip; slope = 1.00f; - occlusion = mix (0.55f, 1.0f, smoothstep (KnobRenderer::skirtRadius, chamferEdge + 0.04f, r)); + occlusion = mix (0.55f, 1.0f, smoothstep (p.grip, p.chamfer + 0.04f, r)); } - else if (r < rimStart) + else if (r < p.rim) { - material = Material::skirt; + zone = Zone::skirt; slope = 0.22f; - occlusion = mix (0.30f, 1.0f, smoothstep (KnobRenderer::skirtRadius, KnobRenderer::skirtRadius + 0.07f, r)); + occlusion = mix (0.30f, 1.0f, smoothstep (p.grip, p.grip + 0.07f, r)); } else { - material = Material::skirt; - const auto t = saturate ((r - rimStart) / (1.0f - rimStart)); + zone = Zone::skirt; + const auto t = saturate ((r - p.rim) / (1.0f - p.rim)); slope = mix (0.22f, 1.35f, t * t); } auto normal = radial * std::sin (slope) + up * std::cos (slope); - if (material == Material::grip && knurled) + if (zone == Zone::grip && shaped && ridgeDepth > 0.0f) { const auto phase = (phi - gripAngle) * static_cast (ridges); - normal = (normal + tangent * (0.62f * std::sin (phase))).normalised(); - occlusion *= 0.62f + 0.38f * (0.5f + 0.5f * std::cos (phase)); + normal = (normal + tangent * (ridgeDepth * std::sin (phase))).normalised(); + occlusion *= 1.0f - 0.61f * ridgeDepth * (0.5f - 0.5f * std::cos (phase)); } - return { normal, material, occlusion }; + return { normal, zone, occlusion }; } -V3 shade (const Surface& surface, float r, float phi, const KnobFinish& finish) noexcept +V3 shadeMaterial (KnobFinish::Material material, juce::Colour colour, const Surface& surface, float r, float phi) noexcept { const auto n = surface.normal; const auto reflected = reflect ({ 0.0f, 0.0f, -1.0f }, n); const auto key = saturate (n.dot (keyLight)); const auto fill = saturate (n.dot (fillLight)) * 0.18f; const auto specular = saturate (n.dot (halfway)); + const auto face = surface.zone == Zone::cap; const V3 white { 1.0f, 1.0f, 1.0f }; + const auto base = linear (colour); - switch (surface.material) + switch (material) { - case Material::cap: - { - const auto metal = linear (finish.cap); - const V3 t { -std::sin (phi), std::cos (phi), 0.0f }; - const auto th = t.dot (halfway); - const auto kajiyaKay = std::sqrt (std::max (0.0f, 1.0f - th * th)); - // Lathe grooves: fine concentric rings with slow variation in depth. - const auto grooves = 0.72f + 0.56f * fbm (r * 310.0f, 3.7f, 3, 11); - const auto sheen = std::pow (kajiyaKay, 64.0f) * grooves; - const auto broad = std::pow (kajiyaKay, 9.0f) * 0.22f; - const auto face = metal * (0.05f + 0.28f * key + fill) + metal * (environment (reflected) * 0.42f) - + metal * ((sheen * 0.95f + broad) * grooves); - // A soft hot spot where the softbox reflects near the centre. - const auto hot = std::exp (-std::pow ((r - 0.18f) / 0.30f, 2.0f)) * 0.05f; - return (face + white * hot) * surface.occlusion; - } - case Material::chamfer: + case KnobFinish::Material::spunAluminium: + case KnobFinish::Material::polishedAluminium: { - const auto metal = linear (finish.cap); - const auto bright = metal * (0.10f + 0.30f * key + fill) + metal * (environment (reflected) * 0.95f) + const auto polished = material == KnobFinish::Material::polishedAluminium; + if (face) + { + const V3 t { -std::sin (phi), std::cos (phi), 0.0f }; + const auto th = t.dot (halfway); + const auto kajiyaKay = std::sqrt (std::max (0.0f, 1.0f - th * th)); + // Lathe grooves: fine concentric rings with slow variation in depth. + const auto grooves = 0.72f + 0.56f * fbm (r * 310.0f, 3.7f, 3, 11); + const auto sheen = std::pow (kajiyaKay, 64.0f) * grooves; + const auto broad = std::pow (kajiyaKay, 9.0f) * 0.22f; + const auto mirror = polished ? 0.85f : 0.42f; + const auto lit = base * (0.05f + 0.28f * key + fill) + base * (environment (reflected) * mirror) + + base * ((sheen * (polished ? 0.55f : 0.95f) + broad) * grooves); + // A soft hot spot where the softbox reflects near the centre. + const auto hot = std::exp (-std::pow ((r - 0.18f) / 0.30f, 2.0f)) * (polished ? 0.09f : 0.05f); + return (lit + white * hot) * surface.occlusion; + } + const auto bright = base * (0.10f + 0.30f * key + fill) + base * (environment (reflected) * (polished ? 1.10f : 0.95f)) + white * (std::pow (specular, 90.0f) * 1.6f); return bright * surface.occlusion; } - case Material::grip: + case KnobFinish::Material::glossPlastic: { - const auto albedo = linear (finish.body); - return (albedo * (0.35f + 2.2f * key + fill) + white * (environment (reflected) * 0.070f) - + white * (std::pow (specular, 18.0f) * 0.20f)) * surface.occlusion; + // A dyed moulding under a clear gloss: dark diffuse body, sharp softbox reflections. + const auto fresnel = 0.04f + 0.96f * std::pow (1.0f - saturate (n.z), 5.0f); + const auto reflection = environment (reflected) * (0.10f + 1.6f * fresnel); + const auto highlight = std::pow (specular, 180.0f) * 1.5f + std::pow (specular, 36.0f) * 0.05f; + return (base * (0.25f + 1.5f * key + fill) + white * (reflection + highlight)) * surface.occlusion; } - case Material::skirt: + case KnobFinish::Material::anodised: default: { - const auto albedo = linear (finish.body); - return (albedo * (0.30f + 1.6f * key + fill) + white * (environment (reflected) * 0.030f) - + white * (std::pow (specular, 14.0f) * 0.07f)) * surface.occlusion; + if (surface.zone == Zone::skirt) + return (base * (0.30f + 1.6f * key + fill) + white * (environment (reflected) * 0.030f) + + white * (std::pow (specular, 14.0f) * 0.07f)) * surface.occlusion; + return (base * (0.35f + 2.2f * key + fill) + white * (environment (reflected) * 0.070f) + + white * (std::pow (specular, 18.0f) * 0.20f)) * surface.occlusion; } } } +V3 shade (const Surface& surface, float r, float phi, const KnobFinish& finish) noexcept +{ + const auto onCap = surface.zone == Zone::cap || surface.zone == Zone::chamfer; + return shadeMaterial (onCap ? finish.capMaterial : finish.bodyMaterial, + onCap ? finish.cap : finish.body, surface, r, phi); +} + template void renderSupersampled (juce::Image& image, int size, int samples, juce::Rectangle area, Fn&& sample) { @@ -173,7 +195,8 @@ void KnobRenderer::prepare (int diameterPx) diameter = diameterPx; shadowMargin = juce::roundToInt (static_cast (diameter) * 0.26f) + 2; // Keep ridge pitch roughly constant in physical pixels, within a believable range. - ridges = juce::jlimit (24, 60, juce::roundToInt (static_cast (diameter) * 0.36f)); + ridges = finish.ridges > 0 ? finish.ridges + : juce::jlimit (24, 60, juce::roundToInt (static_cast (diameter) * 0.36f)); gripAngle = std::numeric_limits::quiet_NaN(); renderBody(); renderShadow(); @@ -182,14 +205,15 @@ void KnobRenderer::prepare (int diameterPx) void KnobRenderer::renderBody() { body = juce::Image (juce::Image::ARGB, diameter, diameter, true); - renderSupersampled (body, diameter, 3, body.getBounds(), [this] (float x, float y, float& weight) + const auto profile = profileFor (finish); + renderSupersampled (body, diameter, 3, body.getBounds(), [this, profile] (float x, float y, float& weight) { const auto r = std::sqrt (x * x + y * y); if (r > 1.0f) return V3 {}; weight = 1.0f; const auto phi = std::atan2 (y, x); - return shade (surfaceAt (r, phi, 0.0f, ridges, false), r, phi, finish); + return shade (surfaceAt (r, phi, 0.0f, ridges, finish.ridgeDepth, false, profile), r, phi, finish); }); } @@ -235,7 +259,8 @@ void KnobRenderer::renderGrip (float angle) const auto radius = static_cast (diameter) * 0.5f; const auto overlap = 1.5f / radius; - const auto inner = chamferEdge - overlap, outer = skirtRadius + overlap; + const auto profile = profileFor (finish); + const auto inner = profile.chamfer - overlap, outer = profile.grip + overlap; // atan2 has 0 pointing right; knob angles have 0 pointing up. const auto ridgeAngle = angle - juce::MathConstants::halfPi; @@ -247,7 +272,7 @@ void KnobRenderer::renderGrip (float angle) // Fade the band edges over matching body pixels so the seam is invisible. weight = smoothstep (inner, inner + overlap, r) * (1.0f - smoothstep (outer - overlap, outer, r)); const auto phi = std::atan2 (y, x); - return shade (surfaceAt (r, phi, ridgeAngle, ridges, true), r, phi, finish); + return shade (surfaceAt (r, phi, ridgeAngle, ridges, finish.ridgeDepth, true, profile), r, phi, finish); }); } } // namespace affine diff --git a/libs/affine-ui/affine_ui/render/affine_KnobRenderer.h b/libs/affine-ui/affine_ui/render/affine_KnobRenderer.h index a03550b..9f32e6a 100644 --- a/libs/affine-ui/affine_ui/render/affine_KnobRenderer.h +++ b/libs/affine-ui/affine_ui/render/affine_KnobRenderer.h @@ -2,13 +2,28 @@ namespace affine { -/** Materials of the family knob. Only the paint colours differ between products. */ +/** Materials and proportions of the family knob. */ struct KnobFinish { - juce::Colour cap { 0xffc3c7cc }; // spun aluminium top - juce::Colour body { 0xff1b1e22 }; // anodised grip and skirt + enum class Material + { + spunAluminium, // lathe-turned metal with an anisotropic highlight + anodised, // satin dyed metal + glossPlastic, // moulded phenolic or bakelite with a clear gloss + polishedAluminium // bright metal that mirrors the studio + }; + + juce::Colour cap { 0xffc3c7cc }; + juce::Colour body { 0xff1b1e22 }; juce::Colour pointer { 0xffe8e4da }; // paint-filled engraving on the cap juce::Colour index { 0xffe8e4da }; // printed index on the skirt + Material capMaterial = Material::spunAluminium; + Material bodyMaterial = Material::anodised; + float capRatio = 0.60f; // cap radius as a fraction of the knob radius + float gripRatio = 0.80f; // outer radius of the knurled or fluted grip + float capDome = 0.06f; // slope of the cap at its edge, in radians + int ridges = 0; // grip ridges; 0 picks a pitch for the size + float ridgeDepth = 0.62f; // 0 gives a smooth grip }; /** @@ -16,7 +31,7 @@ struct KnobFinish while the knob turns: exactly what real hardware does. The rotation-invariant body and the shadow are rendered once per physical - size. Only the knurled grip band is re-shaded when the angle changes. + size. Only the grip band is re-shaded when the angle changes. */ class KnobRenderer { @@ -38,10 +53,6 @@ class KnobRenderer const KnobFinish& getFinish() const noexcept { return finish; } - /** Radii of the rendered geometry as fractions of the knob radius. */ - static constexpr float capRadius = 0.600f; - static constexpr float skirtRadius = 0.800f; - private: void renderBody(); void renderShadow(); diff --git a/libs/affine-ui/affine_ui/render/affine_PanelRenderer.cpp b/libs/affine-ui/affine_ui/render/affine_PanelRenderer.cpp index d6c0913..41ea65f 100644 --- a/libs/affine-ui/affine_ui/render/affine_PanelRenderer.cpp +++ b/libs/affine-ui/affine_ui/render/affine_PanelRenderer.cpp @@ -2,6 +2,73 @@ namespace affine::render { using namespace shading; +namespace +{ +// Hammered enamel: every hammer blow leaves a round, shallow crater of its own size. +// Summing the craters of neighbouring blows gives soft overlapping rims, no creases. +float hammerField (float x, float y, int seed, float& shade) noexcept +{ + const auto cx = static_cast (std::floor (x)), cy = static_cast (std::floor (y)); + auto field = 0.0f, nearest = 8.0f; + for (int j = -1; j <= 1; ++j) + { + for (int i = -1; i <= 1; ++i) + { + const auto cellX = cx + i, cellY = cy + j; + const auto px = static_cast (cellX) + 0.1f + 0.8f * hash (cellX, cellY, seed); + const auto py = static_cast (cellY) + 0.1f + 0.8f * hash (cellX, cellY, seed + 7); + const auto radius = 0.42f + 0.30f * hash (cellX, cellY, seed + 11); + const auto d2 = (px - x) * (px - x) + (py - y) * (py - y); + field += std::exp (-d2 / (radius * radius)) * (0.6f + 0.4f * hash (cellX, cellY, seed + 19)); + if (d2 < nearest) + { + nearest = d2; + shade = hash (cellX, cellY, seed + 13); + } + } + } + return field; +} + +V3 anodisedPixel (const PanelFinish& finish, V3 base, float u, float v, float lx, float ly, int x, int y) noexcept +{ + // Key light from above: the plate is brighter at the top and falls off gently. + const auto vertical = mix (1.16f, 0.78f, smoothstep (-0.1f, 1.05f, v)); + const auto side = 1.0f - 0.12f * std::pow (std::abs (u - 0.46f) * 2.0f, 3.0f); + const auto grain = (valueNoise (lx * 1.35f, ly * 1.35f, 3) - 0.5f) * finish.grain + + (hash (x, y, 9) - 0.5f) * finish.grain * 0.55f; + const auto mottle = (fbm (lx / 95.0f, ly / 95.0f, 3, 17) - 0.5f) * finish.mottle; + const auto brushed = finish.brushing > 0.0f + ? ((fbm (lx / 220.0f, ly * 1.9f, 2, 23) - 0.5f) * 0.10f + (hash (0, y, 29) - 0.5f) * 0.04f) * finish.brushing + : 0.0f; + const auto sheenX = (u - 0.30f) / 0.62f, sheenY = (v + 0.12f) / 0.50f; + const auto sheen = std::exp (-(sheenX * sheenX + sheenY * sheenY)) * finish.sheen * 0.060f; + return base * (vertical * side * (1.0f + grain + mottle + brushed)) + V3 { sheen, sheen, sheen * 1.04f }; +} + +V3 powderPixel (const PanelFinish& finish, V3 base, float u, float v, float lx, float ly, int x, int y) noexcept +{ + const auto vertical = mix (1.10f, 0.84f, smoothstep (-0.1f, 1.05f, v)); + const auto speckle = (hash (x, y, 41) - 0.5f) * finish.grain + (valueNoise (lx * 2.2f, ly * 2.2f, 43) - 0.5f) * finish.grain * 0.8f; + const auto mottle = (fbm (lx / 70.0f, ly / 70.0f, 3, 47) - 0.5f) * finish.mottle; + const auto sheenX = (u - 0.35f) / 0.7f, sheenY = (v + 0.2f) / 0.6f; + const auto sheen = std::exp (-(sheenX * sheenX + sheenY * sheenY)) * finish.sheen * 0.02f; + return base * (vertical * (1.0f + speckle + mottle)) + V3 { sheen, sheen, sheen }; +} + +V3 glassPixel (const PanelFinish& finish, V3 base, float u, float v) noexcept +{ + // Black glass mirrors the room: a faint ceiling at the top edge and one broad + // diagonal reflection of the softbox. + const auto ceiling = std::exp (-v * 9.0f) * 0.010f; + const auto band = (u * 0.55f + v) - 0.30f; + const auto softbox = std::exp (-band * band / 0.020f) * smoothstep (0.85f, 0.05f, u) * finish.sheen * 0.030f; + const auto floor = smoothstep (0.55f, 1.0f, v) * 0.004f; + const auto light = ceiling + softbox + floor; + return base + V3 { light, light, light * 1.08f }; +} +} // namespace + juce::Image faceplate (juce::Rectangle logicalBounds, float scale, const PanelFinish& finish) { const auto width = juce::jmax (1, juce::roundToInt (static_cast (logicalBounds.getWidth()) * scale)); @@ -12,28 +79,76 @@ juce::Image faceplate (juce::Rectangle logicalBounds, float scale, const Pa const auto base = linear (finish.base); const auto inverseScale = 1.0f / scale; + // Hammertone: dimples of a hammered enamel, as a height field lit from the key light. + std::vector relief, tint; + if (finish.texture == PanelFinish::Texture::hammertone) + { + relief.assign (static_cast (width) * static_cast (height), 0.0f); + tint.assign (relief.size(), 0.0f); + const auto cell = 1.0f / juce::jmax (1.0f, finish.dimple); + for (int y = 0; y < height; ++y) + { + for (int x = 0; x < width; ++x) + { + const auto lx = (static_cast (x) + 0.5f) * inverseScale; + const auto ly = (static_cast (y) + 0.5f) * inverseScale; + float id = 0.0f; + const auto craters = hammerField (lx * cell, ly * cell, 61, id); + const auto index = static_cast (y) * static_cast (width) + static_cast (x); + relief[index] = -0.40f * craters + (valueNoise (lx * 0.35f, ly * 0.35f, 67) - 0.5f) * 0.08f; + tint[index] = id; + } + } + } + + const V3 hammerLight = V3 { -0.35f, -0.80f, 0.60f }.normalised(); + for (int y = 0; y < height; ++y) { const auto v = (static_cast (y) + 0.5f) / static_cast (height); const auto ly = (static_cast (y) + 0.5f) * inverseScale; - // Key light from above: the plate is brighter at the top and falls off gently. - const auto vertical = mix (1.16f, 0.78f, smoothstep (-0.1f, 1.05f, v)); for (int x = 0; x < width; ++x) { const auto u = (static_cast (x) + 0.5f) / static_cast (width); const auto lx = (static_cast (x) + 0.5f) * inverseScale; - const auto side = 1.0f - 0.12f * std::pow (std::abs (u - 0.46f) * 2.0f, 3.0f); - const auto grain = (valueNoise (lx * 1.35f, ly * 1.35f, 3) - 0.5f) * finish.grain - + (hash (x, y, 9) - 0.5f) * finish.grain * 0.55f; - const auto mottle = (fbm (lx / 95.0f, ly / 95.0f, 3, 17) - 0.5f) * finish.mottle; - const auto brushed = finish.brushing > 0.0f - ? ((fbm (lx / 220.0f, ly * 1.9f, 2, 23) - 0.5f) * 0.10f + (hash (0, y, 29) - 0.5f) * 0.04f) * finish.brushing - : 0.0f; - const auto sheenX = (u - 0.30f) / 0.62f, sheenY = (v + 0.12f) / 0.50f; - const auto sheen = std::exp (-(sheenX * sheenX + sheenY * sheenY)) * finish.sheen * 0.060f; - const auto light = vertical * side * (1.0f + grain + mottle + brushed); - const auto colour = base * light + V3 { sheen, sheen, sheen * 1.04f }; + V3 colour; + + switch (finish.texture) + { + case PanelFinish::Texture::hammertone: + { + const auto at = [&] (int px, int py) + { + px = juce::jlimit (0, width - 1, px); + py = juce::jlimit (0, height - 1, py); + return relief[static_cast (py) * static_cast (width) + static_cast (px)]; + }; + // Gradients in logical units so the relief looks the same at every scale. + const auto dx = (at (x + 1, y) - at (x - 1, y)) * scale * 0.5f; + const auto dy = (at (x, y + 1) - at (x, y - 1)) * scale * 0.5f; + const auto normal = V3 { -dx * 3.0f, -dy * 3.0f, 1.0f }.normalised(); + const auto lambert = saturate (normal.dot (hammerLight)); + const auto spec = std::pow (saturate (normal.dot ((hammerLight + V3 { 0.0f, 0.0f, 1.0f }).normalised())), 18.0f); + const auto vertical = mix (1.08f, 0.86f, smoothstep (-0.1f, 1.05f, v)); + const auto id = tint[static_cast (y) * static_cast (width) + static_cast (x)]; + const auto cloud = (fbm (lx / 60.0f, ly / 60.0f, 3, 73) - 0.5f) * finish.mottle; + const auto flake = 1.0f + (id - 0.5f) * finish.mottle * 0.6f + cloud + (hash (x, y, 71) - 0.5f) * finish.grain; + const auto sheen = finish.sheen * spec * 0.10f; + colour = base * (vertical * flake * (0.72f + 0.34f * lambert)) + V3 { sheen, sheen, sheen * 0.98f }; + break; + } + case PanelFinish::Texture::powder: + colour = powderPixel (finish, base, u, v, lx, ly, x, y); + break; + case PanelFinish::Texture::glass: + colour = glassPixel (finish, base, u, v); + break; + case PanelFinish::Texture::anodised: + default: + colour = anodisedPixel (finish, base, u, v, lx, ly, x, y); + break; + } auto* pixel = reinterpret_cast (data.getPixelPointer (x, y)); pixel->setARGB (255, @@ -118,4 +233,197 @@ void recess (juce::Graphics& g, juce::Rectangle area, float corner, float g.setGradientFill (left); g.fillRect (area.withWidth (h * 0.6f)); } + +void litWindow (juce::Graphics& g, juce::Rectangle area, juce::Colour backlight, float level) +{ + level = juce::jlimit (0.0f, 1.0f, level); + const auto corner = 2.5f; + // Bezel: a thin dark frame pressed into the plate. + g.setColour (juce::Colours::black.withAlpha (0.55f)); + g.fillRoundedRectangle (area.expanded (1.6f), corner + 1.5f); + g.setColour (juce::Colours::white.withAlpha (0.18f)); + g.drawRoundedRectangle (area.expanded (1.6f).translated (0.0f, 0.8f), corner + 1.5f, 0.8f); + + // Frosted glass: unlit it is a milky grey, lit it glows brightest in the middle. + const auto unlit = backlight.withMultipliedSaturation (0.25f).withMultipliedBrightness (0.42f); + const auto centre = unlit.interpolatedWith (backlight.brighter (0.15f), level); + const auto edge = unlit.darker (0.25f).interpolatedWith (backlight.darker (0.45f), level); + juce::ColourGradient body (centre, area.getCentreX(), area.getCentreY(), + edge, area.getX(), area.getY(), true); + g.setGradientFill (body); + g.fillRoundedRectangle (area, corner); + if (level > 0.0f) + halo (g, area.getCentre(), area.getWidth() * 0.6f, backlight, 0.10f * level); + + juce::Graphics::ScopedSaveState state (g); + g.reduceClipRegion (area.toNearestInt()); + juce::ColourGradient shade (juce::Colours::black.withAlpha (0.30f), area.getX(), area.getY(), + juce::Colours::transparentBlack, area.getX(), area.getY() + 5.0f, false); + g.setGradientFill (shade); + g.fillRect (area.withHeight (5.0f)); +} + +void jewel (juce::Graphics& g, juce::Point centre, float diameter, juce::Colour colour, float level) +{ + level = juce::jlimit (0.0f, 1.0f, level); + const auto r = diameter * 0.5f; + + // Knurled chrome bezel. + const auto bezel = juce::Rectangle (diameter * 1.55f, diameter * 1.55f).withCentre (centre); + softShadow (g, bezel, bezel.getWidth() * 0.5f, 3.0f, { 0.0f, 1.5f }, 0.5f); + juce::ColourGradient chrome (juce::Colour (0xfff3f5f7), bezel.getX(), bezel.getY(), + juce::Colour (0xff4d5157), bezel.getRight(), bezel.getBottom(), false); + chrome.addColour (0.55, juce::Colour (0xff9ea3a9)); + g.setGradientFill (chrome); + g.fillEllipse (bezel); + g.setColour (juce::Colours::black.withAlpha (0.25f)); + for (int i = 0; i < 28; ++i) + { + const auto a = static_cast (i) * juce::MathConstants::twoPi / 28.0f; + g.drawLine (juce::Line (centre.getPointOnCircumference (bezel.getWidth() * 0.43f, a), + centre.getPointOnCircumference (bezel.getWidth() * 0.5f, a)), 0.6f); + } + + if (level > 0.0f) + halo (g, centre, r * 3.6f, colour, 0.38f * level); + + // Faceted jewel: eight facets around a flat table. + const auto unlit = colour.withMultipliedSaturation (0.7f).withMultipliedBrightness (0.28f); + const auto body = unlit.interpolatedWith (colour, level); + for (int i = 0; i < 8; ++i) + { + const auto a0 = static_cast (i) * juce::MathConstants::twoPi / 8.0f; + const auto a1 = a0 + juce::MathConstants::twoPi / 8.0f; + juce::Path facet; + facet.startNewSubPath (centre.getPointOnCircumference (r * 0.45f, a0)); + facet.lineTo (centre.getPointOnCircumference (r, a0)); + facet.lineTo (centre.getPointOnCircumference (r, a1)); + facet.lineTo (centre.getPointOnCircumference (r * 0.45f, a1)); + facet.closeSubPath(); + // Facets that face the key light read brighter. + const auto facing = 0.5f + 0.5f * std::cos (a0 + 0.39f - (-2.2f)); + g.setColour (body.darker (0.55f - 0.45f * facing).brighter (0.25f * level * facing)); + g.fillPath (facet); + } + juce::Path table; + for (int i = 0; i < 8; ++i) + { + const auto p = centre.getPointOnCircumference (r * 0.45f, static_cast (i) * juce::MathConstants::twoPi / 8.0f); + if (i == 0) table.startNewSubPath (p); else table.lineTo (p); + } + table.closeSubPath(); + g.setColour (body.brighter (0.2f + 0.6f * level)); + g.fillPath (table); + g.setColour (juce::Colours::white.withAlpha (0.55f + 0.35f * level)); + g.fillEllipse (juce::Rectangle (r * 0.42f, r * 0.30f).withCentre (centre.translated (-r * 0.30f, -r * 0.38f))); +} + +void rackEar (juce::Graphics& g, juce::Rectangle area, bool leftSide) +{ + // The flange is folded from the same plate: a crease where it meets the panel. + const auto crease = leftSide ? area.getRight() : area.getX(); + g.setColour (juce::Colours::black.withAlpha (0.35f)); + g.fillRect (juce::Rectangle (crease - 1.0f, area.getY(), 1.5f, area.getHeight())); + g.setColour (juce::Colours::white.withAlpha (0.25f)); + g.fillRect (juce::Rectangle (crease + (leftSide ? 0.5f : -1.5f), area.getY(), 1.0f, area.getHeight())); + juce::ColourGradient falloff (juce::Colours::black.withAlpha (leftSide ? 0.10f : 0.0f), area.getX(), area.getY(), + juce::Colours::black.withAlpha (leftSide ? 0.0f : 0.10f), area.getRight(), area.getY(), false); + g.setGradientFill (falloff); + g.fillRect (area); + + for (auto fraction : { 0.20f, 0.80f }) + { + const auto slot = juce::Rectangle (area.getWidth() * 0.62f, 12.0f) + .withCentre ({ area.getCentreX(), area.getY() + area.getHeight() * fraction }); + g.setColour (juce::Colour (0xff0f0e0c)); + g.fillRoundedRectangle (slot, 6.0f); + g.setColour (juce::Colours::white.withAlpha (0.22f)); + g.drawRoundedRectangle (slot.translated (0.0f, 0.8f), 6.0f, 0.8f); + screw (g, slot.getCentre().translated (slot.getWidth() * 0.12f, 0.0f), 10.0f, fraction * 3.0f); + } +} + +void nameplate (juce::Graphics& g, juce::Rectangle area, const juce::String& text, const juce::Font& font) +{ + softShadow (g, area, 3.0f, 4.0f, { 0.0f, 2.0f }, 0.45f); + juce::ColourGradient metal (juce::Colour (0xffe9ebec), area.getX(), area.getY(), + juce::Colour (0xffa4a8ac), area.getX(), area.getBottom(), false); + metal.addColour (0.55, juce::Colour (0xffcfd3d6)); + g.setGradientFill (metal); + g.fillRoundedRectangle (area, 3.0f); + // Horizontal brushing. + for (int i = 0; i < static_cast (area.getHeight() * 1.6f); ++i) + { + const auto y = area.getY() + 1.0f + static_cast (i) * 0.62f; + g.setColour ((shading::hash (i, 3, 91) > 0.5f ? juce::Colours::white : juce::Colours::black) + .withAlpha (0.035f + 0.04f * shading::hash (i, 4, 91))); + g.drawHorizontalLine (juce::roundToInt (y), area.getX() + 2.0f, area.getRight() - 2.0f); + } + g.setColour (juce::Colours::white.withAlpha (0.8f)); + g.drawLine (area.getX() + 2.0f, area.getY() + 0.7f, area.getRight() - 2.0f, area.getY() + 0.7f, 1.0f); + g.setColour (juce::Colours::black.withAlpha (0.35f)); + g.drawRoundedRectangle (area, 3.0f, 0.8f); + + for (auto corner : { area.getTopLeft(), area.getTopRight(), area.getBottomLeft(), area.getBottomRight() }) + { + const auto inset = juce::Point (corner.x < area.getCentreX() ? 6.0f : -6.0f, corner.y < area.getCentreY() ? 6.0f : -6.0f); + const auto rivet = corner + inset; + juce::ColourGradient dome (juce::Colour (0xfffcfdfd), rivet.x - 1.5f, rivet.y - 1.5f, + juce::Colour (0xff6f7378), rivet.x + 2.0f, rivet.y + 2.0f, false); + g.setGradientFill (dome); + g.fillEllipse (juce::Rectangle (4.4f, 4.4f).withCentre (rivet)); + } + + // Engraved lettering: dark fill with a lit lower lip. + g.setFont (font); + g.setColour (juce::Colours::white.withAlpha (0.7f)); + g.drawText (text, area.translated (0.0f, 0.8f), juce::Justification::centred, false); + g.setColour (juce::Colour (0xff1e1e1f)); + g.drawText (text, area, juce::Justification::centred, false); +} + +void endCap (juce::Graphics& g, juce::Rectangle area, bool leftSide) +{ + // Polished aluminium block: mirrors the ceiling near the top and the room below. + juce::ColourGradient block (juce::Colour (0xffdfe3e7), area.getX(), area.getY(), + juce::Colour (0xff6c7177), area.getX(), area.getBottom(), false); + block.addColour (0.18, juce::Colour (0xfff7f8f9)); + block.addColour (0.46, juce::Colour (0xff9aa0a6)); + block.addColour (0.70, juce::Colour (0xffc8cdd2)); + g.setGradientFill (block); + g.fillRect (area); + const auto inner = leftSide ? area.getRight() : area.getX(); + g.setColour (juce::Colours::black.withAlpha (0.55f)); + g.fillRect (juce::Rectangle (inner - (leftSide ? 1.5f : 0.0f), area.getY(), 1.5f, area.getHeight())); + + // Pull handle standing off the cap on two posts. + const auto handle = juce::Rectangle (area.getWidth() * 0.34f, area.getHeight() * 0.66f).withCentre (area.getCentre()); + softShadow (g, handle.translated (leftSide ? 3.0f : -3.0f, 5.0f), handle.getWidth() * 0.5f, 6.0f, {}, 0.55f); + for (auto post : { handle.getY() + 8.0f, handle.getBottom() - 8.0f }) + { + g.setColour (juce::Colour (0xff3e4247)); + g.fillEllipse (juce::Rectangle (handle.getWidth() * 1.1f, 9.0f).withCentre ({ handle.getCentreX(), post })); + } + juce::ColourGradient bar (juce::Colour (0xff5b6066), handle.getX(), handle.getY(), + juce::Colour (0xff4a4f55), handle.getRight(), handle.getY(), false); + bar.addColour (0.28, juce::Colour (0xfffdfeff)); + bar.addColour (0.55, juce::Colour (0xffb8bdc3)); + g.setGradientFill (bar); + g.fillRoundedRectangle (handle, handle.getWidth() * 0.5f); + g.setColour (juce::Colours::black.withAlpha (0.35f)); + g.drawRoundedRectangle (handle, handle.getWidth() * 0.5f, 0.8f); +} + +void screenGlass (juce::Graphics& g, juce::Rectangle area) +{ + softShadow (g, area, 6.0f, 6.0f, { 0.0f, 2.0f }, 0.6f); + g.setColour (juce::Colour (0xff0b0c0e)); + g.fillRoundedRectangle (area.expanded (4.0f), 7.0f); + g.setColour (juce::Colours::white.withAlpha (0.07f)); + g.drawRoundedRectangle (area.expanded (4.0f), 7.0f, 1.0f); + juce::ColourGradient glass (juce::Colour (0xff0a0d10), area.getX(), area.getY(), + juce::Colour (0xff040506), area.getX(), area.getBottom(), false); + g.setGradientFill (glass); + g.fillRoundedRectangle (area, 3.0f); +} } // namespace affine::render diff --git a/libs/affine-ui/affine_ui/render/affine_PanelRenderer.h b/libs/affine-ui/affine_ui/render/affine_PanelRenderer.h index 4bd810e..a9bd342 100644 --- a/libs/affine-ui/affine_ui/render/affine_PanelRenderer.h +++ b/libs/affine-ui/affine_ui/render/affine_PanelRenderer.h @@ -2,14 +2,24 @@ namespace affine { -/** The anodised faceplate every product is machined from. */ +/** The faceplate a product is built on. */ struct PanelFinish { + enum class Texture + { + anodised, // bead-blasted or brushed dyed aluminium + hammertone, // hammered enamel: dimpled metallic paint + powder, // fine matte powder coat + glass // a black glass front + }; + + Texture texture = Texture::anodised; juce::Colour base { 0xff2b3036 }; - float grain = 0.030f; // bead-blast micro texture - float mottle = 0.030f; // slow variation of the anodising - float brushing = 0.0f; // 0 = bead blasted, 1 = horizontally brushed + float grain = 0.030f; // micro texture + float mottle = 0.030f; // slow variation of the finish + float brushing = 0.0f; // anodised only: 0 = bead blasted, 1 = horizontally brushed float sheen = 0.10f; // soft reflection of the studio softbox + float dimple = 7.0f; // hammertone only: cell size in logical pixels }; namespace render @@ -26,5 +36,23 @@ void softShadow (juce::Graphics&, juce::Rectangle area, float corner, flo /** An opening cut into the faceplate: inner shadow at the top, a lit lip at the bottom. */ void recess (juce::Graphics&, juce::Rectangle area, float corner, float depth = 1.0f); + +/** A frosted window lit from behind, as on illuminated legends and counters. `level` 0 is dark. */ +void litWindow (juce::Graphics&, juce::Rectangle area, juce::Colour backlight, float level); + +/** A faceted glass jewel in a knurled chrome bezel. `level` 0 is dark, 1 fully lit. */ +void jewel (juce::Graphics&, juce::Point centre, float diameter, juce::Colour colour, float level); + +/** The flange of a rack panel, with its two mounting slots and screws. */ +void rackEar (juce::Graphics&, juce::Rectangle area, bool leftSide); + +/** A brushed-aluminium name plate riveted to the panel, with engraved lettering. */ +void nameplate (juce::Graphics&, juce::Rectangle area, const juce::String& text, const juce::Font&); + +/** A polished aluminium end cap with a pull handle, as on the sides of a hi-fi front. */ +void endCap (juce::Graphics&, juce::Rectangle area, bool leftSide); + +/** A display screen: thin bezel around dark glass. */ +void screenGlass (juce::Graphics&, juce::Rectangle area); } // namespace render } // namespace affine diff --git a/libs/affine-ui/affine_ui/theme/affine_Theme.h b/libs/affine-ui/affine_ui/theme/affine_Theme.h index 24a92d7..0f53494 100644 --- a/libs/affine-ui/affine_ui/theme/affine_Theme.h +++ b/libs/affine-ui/affine_ui/theme/affine_Theme.h @@ -20,6 +20,16 @@ struct Palette // Display glass and its unlit segments. juce::Colour glass { 0xff07090b }; juce::Colour glassTint { 0xff0d1417 }; + + /** Indicator lamps: small LEDs behind lenses, or faceted jewels in chrome bezels. */ + enum class Lamp { led, jewel }; + Lamp lamp = Lamp::led; + + /** How value readouts are lit: glowing characters on glass, or dark ink on a backlit window. */ + enum class Readout { glowing, backlit }; + Readout readout = Readout::glowing; + juce::Colour readoutBacklight { 0xfff1d58c }; // backlit readouts only + juce::Colour readoutInk { 0xff1f1a12 }; // backlit readouts only }; struct Theme diff --git a/libs/affine-ui/docs/family.png b/libs/affine-ui/docs/family.png deleted file mode 100644 index 4261c43..0000000 Binary files a/libs/affine-ui/docs/family.png and /dev/null differ diff --git a/libs/affine-ui/docs/finishes.png b/libs/affine-ui/docs/finishes.png new file mode 100644 index 0000000..7f14747 Binary files /dev/null and b/libs/affine-ui/docs/finishes.png differ diff --git a/source/PluginEditor.cpp b/source/PluginEditor.cpp index 5df34ca..5291d58 100644 --- a/source/PluginEditor.cpp +++ b/source/PluginEditor.cpp @@ -7,14 +7,22 @@ namespace constexpr float pitchConfidence = 0.1f; constexpr double freshnessMs = 400.0; -const juce::Rectangle modeArea { 690, 22, 170, 64 }; -const juce::Rectangle inputArea { 40, 118, 330, 124 }; -const juce::Rectangle carrierArea { 412, 118, 448, 124 }; -const juce::Rectangle trackingFrame { 32.0f, 274.0f, 240.0f, 194.0f }; -const juce::Rectangle carrierFrame { 284.0f, 274.0f, 424.0f, 194.0f }; -const juce::Rectangle outputFrame { 720.0f, 274.0f, 148.0f, 194.0f }; -const juce::Rectangle waveformArea { 578, 306, 124, 150 }; -constexpr int knobCentreY = 374; +// Layout, in logical pixels. The rack ears frame an 880 px panel. +constexpr float earWidth = 40.0f; +const juce::Rectangle nameplateArea { 64.0f, 22.0f, 300.0f, 42.0f }; +const juce::Rectangle modeArea { 716, 18, 180, 66 }; +const juce::Rectangle inputArea { 64, 134, 212, 146 }; +const juce::Point lockLamp { 316.0f, 146.0f }; +const juce::Rectangle confidenceArea { 292, 160, 158, 124 }; +const juce::Rectangle carrierArea { 480, 134, 416, 146 }; +const juce::Rectangle trackingFrame { 64.0f, 316.0f, 228.0f, 224.0f }; +const juce::Rectangle carrierFrame { 304.0f, 316.0f, 408.0f, 224.0f }; +const juce::Rectangle outputFrame { 724.0f, 316.0f, 172.0f, 224.0f }; +const juce::Rectangle waveformArea { 594, 350, 112, 176 }; +constexpr int knobCentreY = 424; + +const juce::Colour lockGreen { 0xff49d16b }; +const juce::Colour phosphor { 0xff7dffa0 }; juce::String whole(double value) { @@ -36,18 +44,37 @@ juce::Path twoCycles(int waveform) affine::Theme RingmodTheme::theme() { affine::Theme t; - t.panel.base = juce::Colour(0xff1f3538); - t.panel.brushing = 0.45f; - t.knob.pointer = juce::Colour(0xff1c1f22); - t.palette.accent = juce::Colour(0xffff6a1c); - t.palette.glassTint = juce::Colour(0xff111615); - return t; -} - -affine::Theme RingmodTheme::inputDisplayTheme() -{ - auto t = theme(); - t.palette.accent = juce::Colour(0xff5cf2d6); + t.panel.texture = affine::PanelFinish::Texture::hammertone; + t.panel.base = juce::Colour(0xffa9b0b5); + t.panel.grain = 0.035f; + t.panel.mottle = 0.12f; + t.panel.sheen = 0.8f; + t.panel.dimple = 11.0f; + + using Material = affine::KnobFinish::Material; + t.knob.cap = juce::Colour(0xff141414); + t.knob.body = juce::Colour(0xff121212); + t.knob.capMaterial = Material::glossPlastic; + t.knob.bodyMaterial = Material::glossPlastic; + t.knob.pointer = juce::Colour(0xfff2efe6); + t.knob.index = juce::Colour(0xfff2efe6); + t.knob.capRatio = 0.66f; + t.knob.gripRatio = 0.86f; + t.knob.capDome = 0.28f; + t.knob.ridges = 20; + t.knob.ridgeDepth = 0.9f; + + auto& p = t.palette; + p.silkscreen = juce::Colour(0xff1b1a17); + p.silkscreenDim = juce::Colour(0xff25231f); + p.accent = juce::Colour(0xffff6a1c); + p.attention = juce::Colour(0xffffb238); + p.danger = juce::Colour(0xffe5483b); + p.glassTint = juce::Colour(0xff111615); + p.lamp = affine::Palette::Lamp::jewel; + p.readout = affine::Palette::Readout::backlit; + p.readoutBacklight = juce::Colour(0xfff1d58c); + p.readoutInk = juce::Colour(0xff1f1a12); return t; } @@ -77,15 +104,24 @@ class PitchDisplay::ValueInterface final : public juce::AccessibilityValueInterf PitchDisplay::PitchDisplay() { setTitle("Input pitch"); - setTooltip("Tracked input pitch. The carrier runs once a detection clears the Sensitivity mark on the confidence bar."); + setTooltip("Tracked input pitch. The carrier runs once a detection reaches the green zone of the confidence meter, set by Sensitivity."); + note.setCharacters(affine::NixieDisplay::Characters::alphanumeric); + note.setNumTubes(3); + addAndMakeVisible(note); } void PitchDisplay::setTheme(const affine::Theme& t) { theme = t; + note.setTheme(t); repaint(); } +void PitchDisplay::resized() +{ + note.setBounds(16, 8, 180, 94); +} + bool PitchDisplay::hasPitch() const { return trackingMode && live && pitchHz > 0.0f && confidenceValue > pitchConfidence; @@ -111,6 +147,10 @@ void PitchDisplay::setState(float detectedPitchHz, float confidence, bool pitchT trackingMode = pitchTracking; locked = carrierRunning; live = audioLive; + + note.setText(hasPitch() ? juce::String(NoteNames::fromFrequency(pitchHz).c_str()) : juce::String()); + note.setIntensity(hasPitch() ? (locked ? 1.0f : 0.45f) : 0.0f); + auto accessibleValue = getAccessibleValueText(); setDescription(accessibleValue); repaint(); @@ -146,85 +186,36 @@ std::unique_ptr PitchDisplay::createAccessibilityHan void PitchDisplay::paint(juce::Graphics& g) { const auto bounds = getLocalBounds().toFloat(); - affine::render::glass(g, bounds, theme.palette); - - const auto vfd = theme.palette.accent; - const auto unlit = vfd.withMultipliedSaturation(0.45f).withMultipliedBrightness(0.12f); - const auto legendColour = [&](bool lit) { return lit ? vfd.withAlpha(0.9f) : unlit.brighter(0.35f); }; + affine::render::glass(g, bounds.withHeight(110.0f), theme.palette); - // Fourteen-segment note, its unlit segments faintly visible behind it. - const auto noteArea = juce::Rectangle(20.0f, 16.0f, 150.0f, 58.0f); - const auto noteFont = affine::fonts::segment(44.0f, 0.05f); - g.setColour(unlit); - g.setFont(noteFont); - g.drawText("~~~", noteArea, juce::Justification::centredLeft, false); - - juce::String note; juce::String detail; + float level = 1.0f; if (!trackingMode) { - note = "OFF"; detail = "MANUAL CARRIER"; + level = 0.55f; } else if (!live) { detail = "NO AUDIO"; + level = 0.0f; } else if (hasPitch()) { - note = NoteNames::fromFrequency(pitchHz).c_str(); detail = juce::String(pitchHz, 1) + " HZ"; } else { detail = "LISTENING"; + level = 0.55f; } - const auto strength = hasPitch() && !locked ? 0.45f : 1.0f; - noteGlow.draw(g, note, noteFont, noteArea, juce::Justification::centredLeft, vfd.withMultipliedAlpha(strength), 3.0f, 0.9f); - detailGlow.draw(g, detail, affine::fonts::readout(15.0f, 0.08f), { 22.0f, 82.0f, 190.0f, 22.0f }, - juce::Justification::centredLeft, vfd.withMultipliedAlpha(trackingMode && !live ? 0.55f : 0.95f), 2.2f, 0.6f); - - // Lock lamp: lit only while the tracked carrier is actually running. - const auto column = juce::Rectangle(214.0f, 20.0f, 100.0f, 84.0f); - affine::render::lamp(g, { column.getX() + 6.0f, column.getY() + 8.0f }, 7.0f, vfd, locked ? 1.0f : 0.0f); - g.setFont(affine::fonts::label(11.0f, 0.22f)); - g.setColour(legendColour(locked)); - g.drawText("LOCK", juce::Rectangle(column.getX() + 18.0f, column.getY() + 1.0f, 70.0f, 14.0f), - juce::Justification::centredLeft, false); - - g.setColour(legendColour(trackingMode && live)); - g.drawText("CONFIDENCE", juce::Rectangle(column.getX(), column.getY() + 30.0f, 100.0f, 14.0f), - juce::Justification::centredLeft, false); - - const auto bar = juce::Rectangle(column.getX(), column.getY() + 48.0f, 96.0f, 12.0f); - const auto segments = 10; - const auto gap = 2.4f; - const auto segmentWidth = (bar.getWidth() - gap * static_cast(segments - 1)) / static_cast(segments); - const auto level = trackingMode && live ? juce::jlimit(0.0f, 1.0f, confidenceValue) : 0.0f; - for (int i = 0; i < segments; ++i) - { - const auto segment = juce::Rectangle(bar.getX() + static_cast(i) * (segmentWidth + gap), bar.getY(), - segmentWidth, bar.getHeight()); - const auto lit = juce::jlimit(0.0f, 1.0f, level * static_cast(segments) - static_cast(i)); - if (lit > 0.0f) - { - g.setColour(vfd.withAlpha(0.16f * lit)); - g.fillRoundedRectangle(segment.expanded(2.0f), 2.5f); - } - g.setColour(unlit.interpolatedWith(vfd, lit)); - g.fillRoundedRectangle(segment, 1.2f); - } - - // The Sensitivity threshold a detection must clear before the carrier follows it. - if (trackingMode) - { - const auto x = bar.getX() + bar.getWidth() * juce::jlimit(0.0f, 1.0f, threshold); - juce::Path marker; - marker.addTriangle(x, bar.getBottom() + 3.0f, x - 3.5f, bar.getBottom() + 9.0f, x + 3.5f, bar.getBottom() + 9.0f); - g.setColour(vfd.withAlpha(live ? 0.85f : 0.4f)); - g.fillPath(marker); - } + // An amber-lit legend window under the tubes. + const auto window = juce::Rectangle(0.0f, 118.0f, bounds.getWidth(), 26.0f); + affine::render::litWindow(g, window, theme.palette.readoutBacklight, level); + g.setColour(theme.palette.readoutInk.withMultipliedAlpha(0.35f + 0.6f * level)); + g.setFont(affine::fonts::readout(15.0f, 0.08f)); + g.drawText(detail, window, juce::Justification::centred, false); } //============================================================================== @@ -250,7 +241,7 @@ class CarrierDisplay::ValueInterface final : public juce::AccessibilityValueInte CarrierDisplay::CarrierDisplay() { setTitle("Carrier frequency"); - setTooltip("Frequency the carrier oscillator is running at. Dark while the tracker has no pitch and the effect stays dry."); + setTooltip("Frequency the carrier oscillator is running at, with its waveform on the scope. Dark while the tracker has no pitch and the effect stays dry."); counter.setNumTubes(6); addAndMakeVisible(counter); } @@ -287,7 +278,7 @@ void CarrierDisplay::setState(float carrierHz, int waveformIndex) void CarrierDisplay::resized() { - counter.setBounds(16, 10, 262, getHeight() - 20); + counter.setBounds(14, 10, 250, getHeight() - 20); } std::unique_ptr CarrierDisplay::createAccessibilityHandler() @@ -299,42 +290,56 @@ std::unique_ptr CarrierDisplay::createAccessibilityH void CarrierDisplay::paint(juce::Graphics& g) { - affine::render::glass(g, getLocalBounds().toFloat(), theme.palette); + const auto bounds = getLocalBounds().toFloat(); + affine::render::glass(g, bounds, theme.palette); const auto neon = theme.palette.accent; const auto running = carrier > 0.0f; - const auto unitArea = juce::Rectangle(286.0f, 74.0f, 40.0f, 26.0f); + const auto unitArea = juce::Rectangle(268.0f, 94.0f, 30.0f, 24.0f); unit.draw(g, "Hz", unitArea, neon.withMultipliedAlpha(running ? 1.0f : 0.25f), 2.5f, running ? 0.8f : 0.0f, [&](juce::Graphics& lg) { - lg.setFont(affine::fonts::wordmark(14.0f, 0.06f)); + lg.setFont(affine::fonts::wordmark(13.0f, 0.06f)); lg.drawText("Hz", unitArea.withZeroOrigin(), juce::Justification::centredLeft, false); }); - // A small scope screen tracing the selected carrier shape. - const auto scope = juce::Rectangle(338.0f, 20.0f, 94.0f, getHeight() - 40.0f); - g.setColour(juce::Colour(0xff0a0e0d)); - g.fillRoundedRectangle(scope, 3.0f); - g.setColour(neon.withAlpha(0.10f)); - for (int i = 1; i < 4; ++i) - { - const auto x = scope.getX() + scope.getWidth() * static_cast(i) / 4.0f; - g.drawVerticalLine(juce::roundToInt(x), scope.getY() + 2.0f, scope.getBottom() - 2.0f); - } - for (int i = 1; i < 4; ++i) + // A round phosphor scope tracing the selected carrier shape. + const auto crt = juce::Rectangle(112.0f, 112.0f).withCentre({ bounds.getRight() - 64.0f, bounds.getCentreY() }); + juce::ColourGradient bezel(juce::Colour(0xff3b3f44), crt.getX(), crt.getY(), juce::Colour(0xff0b0c0e), crt.getX(), crt.getBottom(), false); + g.setGradientFill(bezel); + g.fillEllipse(crt.expanded(5.0f)); + const auto screen = crt.reduced(4.0f); + juce::ColourGradient glassFace(juce::Colour(0xff0f2616), screen.getCentreX(), screen.getCentreY(), + juce::Colour(0xff030805), screen.getX(), screen.getY(), true); + g.setGradientFill(glassFace); + g.fillEllipse(screen); { - const auto y = scope.getY() + scope.getHeight() * static_cast(i) / 4.0f; - g.drawHorizontalLine(juce::roundToInt(y), scope.getX() + 2.0f, scope.getRight() - 2.0f); + juce::Graphics::ScopedSaveState state(g); + juce::Path clip; + clip.addEllipse(screen); + g.reduceClipRegion(clip); + g.setColour(phosphor.withAlpha(0.12f)); + for (int i = 1; i < 6; ++i) + { + const auto x = screen.getX() + screen.getWidth() * static_cast(i) / 6.0f; + const auto y = screen.getY() + screen.getHeight() * static_cast(i) / 6.0f; + g.drawVerticalLine(juce::roundToInt(x), screen.getY(), screen.getBottom()); + g.drawHorizontalLine(juce::roundToInt(y), screen.getX(), screen.getRight()); + } + const auto traceArea = screen.reduced(20.0f, 30.0f); + trace.draw(g, "wave" + juce::String(waveform), traceArea, phosphor.withMultipliedAlpha(running ? 1.0f : 0.3f), 3.0f, + running ? 1.0f : 0.0f, + [&](juce::Graphics& lg) + { + auto path = twoCycles(waveform); + path.applyTransform(path.getTransformToScaleToFit(traceArea.withZeroOrigin(), false)); + lg.strokePath(path, juce::PathStrokeType(1.8f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); + }); + juce::ColourGradient reflection(juce::Colours::white.withAlpha(0.10f), screen.getX(), screen.getY(), + juce::Colours::transparentWhite, screen.getCentreX(), screen.getCentreY(), false); + g.setGradientFill(reflection); + g.fillEllipse(screen); } - const auto traceArea = scope.reduced(9.0f, 16.0f); - trace.draw(g, "wave" + juce::String(waveform), traceArea, neon.withMultipliedAlpha(running ? 1.0f : 0.3f), 2.5f, - running ? 0.9f : 0.0f, - [&](juce::Graphics& lg) - { - auto path = twoCycles(waveform); - path.applyTransform(path.getTransformToScaleToFit(traceArea.withZeroOrigin(), false)); - lg.strokePath(path, juce::PathStrokeType(1.6f, juce::PathStrokeType::curved, juce::PathStrokeType::rounded)); - }); } //============================================================================== @@ -354,12 +359,26 @@ HdnRingmodAudioProcessorEditor::HdnRingmodAudioProcessorEditor(HdnRingmodAudioPr { setLookAndFeel(&lookAndFeel); setOpaque(true); + faceplate.setShowsScrews(false); - pitchDisplay.setTheme(RingmodTheme::inputDisplayTheme()); + pitchDisplay.setTheme(theme); carrierDisplay.setTheme(theme); addAndMakeVisible(pitchDisplay); addAndMakeVisible(carrierDisplay); + affine::NeedleMeter::Scale confidence; + confidence.toPosition = [](float value) { return value; }; + confidence.majors = { 0.0f, 0.25f, 0.5f, 0.75f, 1.0f }; + confidence.minors = { 0.125f, 0.375f, 0.625f, 0.875f }; + confidence.format = [](float value) { return whole(value * 100.0); }; + confidence.unit = "%"; + confidence.caption = "Confidence"; + confidenceMeter.setTheme(theme); + confidenceMeter.setScale(confidence); + confidenceMeter.setBallistics(4.0f, 0.8f); + confidenceMeter.setTitle("Confidence"); + addAndMakeVisible(confidenceMeter); + const auto percent = [](double value) { return whole(value); }; smoothingKnob.setScale({ 0, 25, 50, 75, 100 }, percent); sensitivityKnob.setScale({ 0, 25, 50, 75, 100 }, percent); @@ -382,11 +401,11 @@ HdnRingmodAudioProcessorEditor::HdnRingmodAudioProcessorEditor(HdnRingmodAudioPr modeKeys.onChange = [this](int) { updateModePresentation(); }; modeKeys.setLegends({ "Track", "Manual" }); - modeKeys.setKeySize(46.0f, 28.0f); + modeKeys.setKeySize(52.0f, 30.0f); waveformKeys.setLegends({ "Sine", "Tri", "Square", "Saw" }); waveformKeys.setGlyphs({ affine::glyphs::sine(), affine::glyphs::triangle(), affine::glyphs::square(), affine::glyphs::saw() }); - waveformKeys.setKeySize(40.0f, 30.0f); + waveformKeys.setKeySize(44.0f, 34.0f); waveformKeys.setColumns(2); int order = 1; @@ -404,8 +423,12 @@ HdnRingmodAudioProcessorEditor::HdnRingmodAudioProcessorEditor(HdnRingmodAudioPr for (auto* knob : { &smoothingKnob, &sensitivityKnob, &rateMultKnob, &manualRateKnob, &mixKnob }) knob->setTheme(theme); - modeKeys.setTheme(theme); - waveformKeys.setTheme(theme); + for (auto* keys : { &modeKeys, &waveformKeys }) + { + keys->setTheme(theme); + keys->setStyle(affine::KeyButton::Style::pianoKey); + keys->setKeyColour(juce::Colour(0xffece5d2)); + } lastBlockCount = processorRef.processedBlocks.load(std::memory_order_acquire); lastBlockMs = juce::Time::getMillisecondCounterHiRes() - 1000.0; @@ -425,42 +448,50 @@ HdnRingmodAudioProcessorEditor::~HdnRingmodAudioProcessorEditor() void HdnRingmodAudioProcessorEditor::paint(juce::Graphics& graphics) { faceplate.paint(graphics, getLocalBounds(), theme, [this](juce::Graphics& plate) { print(plate); }); + affine::render::jewel(graphics, lockLamp, 10.0f, lockGreen, lockLit ? 1.0f : 0.0f); } void HdnRingmodAudioProcessorEditor::print(juce::Graphics& g) { using namespace affine::silkscreen; const auto& palette = theme.palette; - - wordmark(g, "Ring Modulator", "HDN / Pitch-tracking carrier", { 40.0f, 26.0f }, palette); - legend(g, "Source", modeArea.toFloat().withWidth(76.0f).withX(static_cast(modeArea.getX()) - 80.0f) - .withHeight(30.0f).translated(0.0f, 4.0f), + const auto w = static_cast(width), h = static_cast(height); + + affine::render::rackEar(g, { 0.0f, 0.0f, earWidth, h }, true); + affine::render::rackEar(g, { w - earWidth, 0.0f, earWidth, h }, false); + affine::render::nameplate(g, nameplateArea, "RING MODULATOR", affine::fonts::label(24.0f, 0.36f)); + g.setColour(palette.silkscreenDim); + g.setFont(affine::fonts::label(12.0f, 0.24f)); + g.drawText("HDN / PITCH-TRACKING CARRIER", juce::Rectangle(nameplateArea.getX() + 1.0f, nameplateArea.getBottom() + 6.0f, 320.0f, 14.0f), + juce::Justification::centredLeft, false); + legend(g, "Source", modeArea.toFloat().withWidth(70.0f).withX(static_cast(modeArea.getX()) - 76.0f) + .withHeight(32.0f).translated(0.0f, 2.0f), palette, juce::Justification::centredRight, true); - groove(g, 32.0f, static_cast(width) - 32.0f, 94.0f); - - legend(g, "Input pitch", inputArea.toFloat().withHeight(14.0f).translated(0.0f, -18.0f), palette); - legend(g, "Carrier frequency", carrierArea.toFloat().withHeight(14.0f).translated(0.0f, -18.0f), palette); + groove(g, earWidth + 12.0f, w - earWidth - 12.0f, 100.0f); + legend(g, "Input pitch", inputArea.toFloat().withHeight(14.0f).translated(0.0f, -20.0f), palette); + legend(g, "Carrier frequency", carrierArea.toFloat().withHeight(14.0f).translated(0.0f, -20.0f), palette); g.setColour(palette.silkscreen); + g.setFont(affine::fonts::label(11.5f, 0.24f)); + g.drawText("LOCK", juce::Rectangle(lockLamp.x + 14.0f, lockLamp.y - 7.0f, 60.0f, 14.0f), juce::Justification::centredLeft, false); g.setFont(affine::fonts::wordmark(22.0f, 0.0f)); g.drawText(juce::String(juce::CharPointer_UTF8("\xc3\x97")), - juce::Rectangle(static_cast(inputArea.getRight()), 164.0f, - static_cast(carrierArea.getX() - inputArea.getRight()), 26.0f), - juce::Justification::centred, false); + juce::Rectangle(450.0f, 194.0f, 30.0f, 26.0f), juce::Justification::centred, false); frame(g, trackingFrame, "Tracking", palette); frame(g, carrierFrame, "Carrier", palette); frame(g, outputFrame, "Output", palette); - legend(g, "Waveform", waveformArea.toFloat().withHeight(14.0f).translated(0.0f, -16.0f), palette, + legend(g, "Waveform", waveformArea.toFloat().withHeight(14.0f).translated(0.0f, -18.0f), palette, juce::Justification::centred, true); - makersMark(g, { 34.0f, static_cast(height) - 10.0f }, palette); + makersMark(g, { earWidth + 24.0f, h - 12.0f }, palette); } void HdnRingmodAudioProcessorEditor::resized() { modeKeys.setBounds(modeArea); pitchDisplay.setBounds(inputArea); + confidenceMeter.setBounds(confidenceArea); carrierDisplay.setBounds(carrierArea); const auto place = [](affine::Knob& knob, int centreX) @@ -468,12 +499,12 @@ void HdnRingmodAudioProcessorEditor::resized() knob.setBounds(knob.getBoundsForCentre({ centreX, knobCentreY })); }; - place(smoothingKnob, 92); - place(sensitivityKnob, 212); - place(rateMultKnob, 358); - place(manualRateKnob, 500); + place(smoothingKnob, 122); + place(sensitivityKnob, 234); + place(rateMultKnob, 386); + place(manualRateKnob, 516); waveformKeys.setBounds(waveformArea); - place(mixKnob, 794); + place(mixKnob, 810); } int HdnRingmodAudioProcessorEditor::getControlParameterIndex(juce::Component& component) @@ -517,11 +548,33 @@ void HdnRingmodAudioProcessorEditor::timerCallback() const auto pitchTracking = modeKeys.getSelectedIndex() != 1; const auto manualRate = static_cast(manualRateKnob.getValue()); const auto carrierHz = processorRef.currentCarrierHz.load(std::memory_order_relaxed); + const auto confidence = processorRef.currentConfidence.load(std::memory_order_relaxed); const auto sensitivity = processorRef.apvts.getRawParameterValue(ParameterIDs::sensitivity)->load() / 100.0f; + const auto locked = live && pitchTracking && carrierHz > 0.0f; - pitchDisplay.setState(processorRef.currentPitchHz.load(std::memory_order_relaxed), - processorRef.currentConfidence.load(std::memory_order_relaxed), - pitchTracking, manualRate, sensitivity, live && pitchTracking && carrierHz > 0.0f, live); + pitchDisplay.setState(processorRef.currentPitchHz.load(std::memory_order_relaxed), confidence, + pitchTracking, manualRate, sensitivity, locked, live); carrierDisplay.setState(pitchTracking ? (live ? carrierHz : 0.0f) : manualRate, waveformKeys.getSelectedIndex()); + + // The green zone of the confidence meter starts at the Sensitivity threshold. + if (!juce::approximatelyEqual(shownThreshold, sensitivity)) + { + shownThreshold = sensitivity; + affine::NeedleMeter::Face face; + face.backlight = juce::Colour(0xfff2d48a); + face.ink = juce::Colour(0xff20190f); + face.zone = juce::Colour(0xff2f9e4f); + face.zoneFrom = sensitivity; + face.mirror = false; + face.bezel = affine::NeedleMeter::Face::Bezel::chrome; + confidenceMeter.setFace(face); + } + confidenceMeter.setReading(confidence, live && pitchTracking); + + if (locked != lockLit) + { + lockLit = locked; + repaint(juce::Rectangle(40, 40).withCentre(lockLamp.toInt())); + } updateModePresentation(); } diff --git a/source/PluginEditor.h b/source/PluginEditor.h index 8029c29..9978723 100644 --- a/source/PluginEditor.h +++ b/source/PluginEditor.h @@ -5,9 +5,8 @@ namespace RingmodTheme { -// Petrol anodising, neon-orange Nixie carrier and a cyan vacuum-fluorescent input display. +// Broadcast finish: blue-grey hammertone, bakelite knobs, neon tubes and a phosphor scope. affine::Theme theme(); -affine::Theme inputDisplayTheme(); } class PitchDisplay final : public juce::Component, @@ -20,6 +19,7 @@ class PitchDisplay final : public juce::Component, void setState(float detectedPitchHz, float confidence, bool pitchTracking, float manualRateHz, float acceptanceThreshold, bool carrierRunning, bool audioLive); void paint(juce::Graphics&) override; + void resized() override; std::unique_ptr createAccessibilityHandler() override; juce::String getAccessibleValueText() const; @@ -29,7 +29,7 @@ class PitchDisplay final : public juce::Component, bool hasPitch() const; affine::Theme theme; - affine::render::GlowText noteGlow, detailGlow; + affine::NixieDisplay note; float pitchHz = 0.0f; float confidenceValue = 0.0f; float manualRate = 440.0f; @@ -75,8 +75,8 @@ class HdnRingmodAudioProcessorEditor : public juce::AudioProcessorEditor, void resized() override; int getControlParameterIndex(juce::Component&) override; - static constexpr int width = 900; - static constexpr int height = 490; + static constexpr int width = 960; + static constexpr int height = 570; private: void timerCallback() override; @@ -89,6 +89,7 @@ class HdnRingmodAudioProcessorEditor : public juce::AudioProcessorEditor, affine::Faceplate faceplate; PitchDisplay pitchDisplay; CarrierDisplay carrierDisplay; + affine::NeedleMeter confidenceMeter; affine::Knob smoothingKnob, sensitivityKnob, rateMultKnob, manualRateKnob, mixKnob; affine::SelectorKeys modeKeys, waveformKeys; @@ -96,6 +97,8 @@ class HdnRingmodAudioProcessorEditor : public juce::AudioProcessorEditor, uint32_t lastBlockCount = 0; double lastBlockMs = 0.0; int lastModeIndex = -1; + float shownThreshold = -1.0f; + bool lockLit = false; JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(HdnRingmodAudioProcessorEditor) }; diff --git a/tests/TestEditor.cpp b/tests/TestEditor.cpp index b7f892c..840f0d9 100644 --- a/tests/TestEditor.cpp +++ b/tests/TestEditor.cpp @@ -108,7 +108,7 @@ TEST_CASE("Ring modulator theme: printed and emitted colours keep readable contr } REQUIRE(contrastRatio(palette.accent, palette.glass) >= 4.5f); - REQUIRE(contrastRatio(RingmodTheme::inputDisplayTheme().palette.accent, palette.glass) >= 4.5f); + REQUIRE(contrastRatio(palette.readoutInk, palette.readoutBacklight) >= 4.5f); } TEST_CASE("Family typefaces are bundled rather than taken from the system") @@ -377,6 +377,14 @@ TEST_CASE("Editor: renders every display state for review") }; auto capture = [&](const juce::String& name, float scale) { + // Show the steady state rather than a frame of a needle's or pointer's movement. + for (auto* child : editor.getChildren()) + { + if (auto* meter = dynamic_cast(child)) + meter->settle(); + if (auto* dial = dynamic_cast(child)) + dial->settle(); + } auto image = editor.createComponentSnapshot(editor.getLocalBounds(), true, scale); auto file = folder.getChildFile(name + ".png"); file.deleteFile();