Skip to content
Merged
28 changes: 23 additions & 5 deletions app/MainComponent.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -351,8 +351,8 @@ MainComponent::MainComponent(std::string explicitVolume, juce::File dataOverride
toggleCountIn(slot, row->info.countIn);
};
rhythmPane.onEdit = [this](int slot, usecases::rhythm::Edits edits) {
if (!pedalBusy && slotRowFor(slot) != nullptr)
editRhythm(slot, std::move(edits));
if (const SlotRow* row = pedalBusy ? nullptr : slotRowFor(slot))
editRhythm(slot, row->info.rhythm.beat, std::move(edits));
};
inspector.onPlayStopEdited = [this](int slot, usecases::playstop::Edits edits) {
if (!pedalBusy && slotRowFor(slot) != nullptr)
Expand Down Expand Up @@ -2013,19 +2013,37 @@ void MainComponent::toggleCountIn(int slot, bool currentlyOn)
// screen's fields. The change rides as the job's note, in the card's own
// words (usecases::rhythm::describe): the banner reads "Rhythm on slot 7 —
// kit Jazz", and the history row keeps the same words, not "field 5 = 2".
void MainComponent::editRhythm(int slot, usecases::rhythm::Edits edits)
// The words are made here, before the job is enqueued and before the
// history opens its row, from the memory's beat as the table shows it: a
// pattern at a beat whose list is not charted is refused at this line, and
// no row is written for it (#149). The card itself is checked again on the
// worker, against the beat it holds then.
void MainComponent::editRhythm(int slot, long long beat, usecases::rhythm::Edits edits)
{
if (!CardPermissions::of(snapshot.family).rhythm)
return;
// Caught rather than dropped: the tab locks PATTERN where these words
// would be refused, so reaching the catch means the tab and the core
// disagree, and a silent drop would hide exactly that. The banner shows
// the core's sentence; no job and no history row follow. Nothing above
// this frame catches on the message thread — a throw here would end
// the app, not report.
std::shared_ptr<juce::String> note;
try {
note = std::make_shared<juce::String>(utf8(usecases::rhythm::describe(edits, beat)));
} catch (const Error& e) {
banners.showError(banners::Source::job,
"Rhythm on slot " + juce::String(slot) + ": " + utf8(e.what()));
return;
}
const auto options = makeWriteOptions();
worker.enqueue(recorded("rhythm", options,
{ "Rhythm on slot " + juce::String(slot),
slot,
[slot, edits, options](const volume::fs::path& volumePath) {
commands::setRhythm(volumePath, slot, edits, options);
},
std::make_shared<juce::String>(
utf8(usecases::rhythm::describe(edits))) }));
std::move(note) }));
}

// One job per change on the Start & Stop card, the twin of editRhythm: the
Expand Down
2 changes: 1 addition & 1 deletion app/MainComponent.h
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ class MainComponent final : public juce::Component,
std::vector<std::string> hashes);
void toggleOneShot(int slot, bool currentlyOn);
void toggleCountIn(int slot, bool currentlyOn);
void editRhythm(int slot, usecases::rhythm::Edits edits);
void editRhythm(int slot, long long beat, usecases::rhythm::Edits edits);
void editPlayStop(int slot, usecases::playstop::Edits edits);
void releasePlayerIfHolding(int slotA, int slotB);
void choosePushWav(int slot, bool slotOccupied);
Expand Down
80 changes: 80 additions & 0 deletions app/RefusalWords.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Copyright (c) 2026 Darwin's Cat. Part of Looper Cat — see LICENSE.
// SPDX-License-Identifier: AGPL-3.0-or-later

#pragma once

#include "Strings.h"

#include <loopercat/usecases/Beat.hpp>
#include <loopercat/usecases/CountIn.hpp>
#include <loopercat/usecases/Rhythm.hpp>

#include <juce_core/juce_core.h>

//==============================================================================
// loopercat::words — the player's one line for a refusal the core states as
// a fact. The core refuses a click that would write a PATTERN number at a
// beat whose list is not charted (Beat.hpp, #149) and says so in full for
// the banner and the log; the Rhythm tab, the Play Count-In card and the
// table say it in one line beside the lamp, before the click, with no date
// and no field name in it. One place for those lines, so the three never
// drift and there is one list to reword.
//==============================================================================
namespace loopercat::words
{

// The beat as the screen prints it ("6/4"), or the truth about a number the
// manual's list does not have — never a throw: these lines are painted on
// every snapshot (Beat.hpp, label).
inline juce::String beatLabel(long long beat) { return utf8(usecases::beat::label(beat)); }

// Beside the rhythm switch, where its click is refused.
inline juce::String rhythmSwitchRefused(usecases::rhythm::SwitchRefusal why, long long beat)
{
switch (why) {
case usecases::rhythm::SwitchRefusal::onNeedsGroove:
return "Switching on at " + beatLabel(beat)
+ " needs a pattern: choose one on the pedal first.";
case usecases::rhythm::SwitchRefusal::offNeedsBlank:
return "Switching off at " + beatLabel(beat)
+ " would drop the pattern: switch the count-in off first.";
}
jassertfalse;
return {};
}

// Beside the Play Count-In switch (the card, the table cell's hover), where
// its click is refused.
inline juce::String countInRefused(usecases::countin::Refusal why, long long beat)
{
switch (why) {
case usecases::countin::Refusal::onBorrowsSection:
return "Switching on at " + beatLabel(beat)
+ " would replace the pattern: switch the rhythm on first.";
case usecases::countin::Refusal::offReturnsSection:
return "Switching off at " + beatLabel(beat)
+ " would reset the pattern: switch the count-in off on the pedal instead.";
}
jassertfalse;
return {};
}

// The BEAT caption and the line beside the switch while the count-in
// borrows the rhythm section at 4/4 (Rhythm.hpp, beatHeldByCountIn): the
// caption in the shape the take's lock takes, the line saying the way out.
inline juce::String beatCaptionHeld() { return "BEAT (held by the count-in)"; }
inline juce::String beatHeldByCountIn()
{
return "BEAT stays at 4/4 while the count-in is on: switch the count-in off first.";
}

// The PATTERN caption at a beat whose list is not charted, in the shape BEAT
// takes under a take ("BEAT (fixed by the take)").
inline juce::String patternCaptionLocked(long long beat)
{
if (const auto named = usecases::beat::nameIfListed(beat))
return "PATTERN (list at " + utf8(std::string(*named)) + " not charted)";
return "PATTERN (BEAT " + juce::String(beat) + " is not in the manual's list)";
}

} // namespace loopercat::words
51 changes: 41 additions & 10 deletions app/RhythmPane.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#include "RhythmPane.h"

#include "RefusalWords.h"
#include "Strings.h"

#include <felitronics/appkit/Brand.h>
Expand Down Expand Up @@ -187,13 +188,30 @@ void RhythmControls::setValues(const rhythm::Values& values)

void RhythmControls::refresh()
{
pattern_.setSelectedId(static_cast<int>(values_.pattern) + 1, juce::dontSendNotification);
// PATTERN is an index into the current BEAT's list, and only the 4/4
// list is charted (Rhythm.hpp, #149): elsewhere the box shows the number
// the card holds — as text, with no item behind it — and is a lamp, in
// the shape BEAT takes under a take. A 4/4 name for a 6/4 number is the
// one thing this tab must never show. The caption never throws: a memory
// can hold a beat the manual's list lacks, and this runs on every snapshot.
if (values_.patternCharted)
pattern_.setSelectedId(static_cast<int>(values_.pattern) + 1, juce::dontSendNotification);
else
pattern_.setText(juce::String(values_.pattern), juce::dontSendNotification);
pattern_.setEnabled(isEnabled() && values_.patternCharted);
patternCaption_.setText(values_.patternCharted ? juce::String("PATTERN")
: words::patternCaptionLocked(values_.beat),
juce::dontSendNotification);
kit_.setSelectedId(static_cast<int>(values_.kit) + 1, juce::dontSendNotification);
beat_.setSelectedId(static_cast<int>(values_.beat) + 1, juce::dontSendNotification);
variation_.setSelectedId(static_cast<int>(values_.variation) + 1, juce::dontSendNotification);
// The manual's rule, said where it applies: a recorded take fixes the beat.
beat_.setEnabled(isEnabled() && !values_.beatLocked);
beatCaption_.setText(values_.beatLocked ? "BEAT (fixed by the take)" : "BEAT",
// The manual's rule, said where it applies: a recorded take fixes the
// beat. And ours (Rhythm.hpp, #149): while the count-in borrows the
// section at 4/4, BEAT stays — the way out is on the pane's line.
beat_.setEnabled(isEnabled() && !values_.beatLocked && !values_.beatHeldByCountIn);
beatCaption_.setText(values_.beatLocked ? juce::String("BEAT (fixed by the take)")
: values_.beatHeldByCountIn ? words::beatCaptionHeld()
: juce::String("BEAT"),
juce::dontSendNotification);

// A field being typed in is the user's, not ours.
Expand All @@ -214,11 +232,11 @@ void RhythmControls::refresh()
// not have until the job ends.
void RhythmControls::enablementChanged()
{
for (auto* control : std::initializer_list<juce::Component*> { &pattern_, &kit_, &variation_,
&level_, &reverb_, &toneLow_,
for (auto* control : std::initializer_list<juce::Component*> { &kit_, &variation_, &level_,
&reverb_, &toneLow_,
&toneHigh_ })
control->setEnabled(isEnabled());
refresh(); // also re-applies BEAT's own lock
refresh(); // also re-applies BEAT's own lock, and PATTERN's at an uncharted beat
}

void RhythmControls::resized()
Expand Down Expand Up @@ -319,9 +337,22 @@ void RhythmPane::refresh()
const rhythm::Values& v = info_.rhythm;
switch_.setState(v.on, v.on ? "Drums play with this memory."
: "No drums with this memory.");
// The groove that "off" would forget: only with a count-in in front of
// playing drums does off mean Blank rather than State off (Rhythm.hpp).
cost_.setText(v.on && info_.countIn
// A click the core would refuse is not offered: at an uncharted beat the
// two clicks that write a PATTERN number make the switch a lamp, and the
// line beside it says why in the player's words (RefusalWords.h) — the
// same typed refusal the core would throw, so the tab and a banner can
// never disagree about when (Rhythm.hpp, switchRefusal).
const auto refused = rhythm::switchRefusal(v, info_.countIn, !v.on);
if (refused)
switch_.setEnabled(false);
// Otherwise the BEAT that stays while the count-in borrows the section,
// or the groove that "off" would forget: only with a count-in in front
// of playing drums does off mean Blank rather than State off
// (Rhythm.hpp). That click is refused at an uncharted beat, so the name
// is looked up only where the list is charted.
cost_.setText(refused ? words::rhythmSwitchRefused(*refused, v.beat)
: v.beatHeldByCountIn ? words::beatHeldByCountIn()
: v.on && info_.countIn
? "Switching it off keeps the count-in and forgets "
+ name(rhythm::patternName(v.pattern)) + "."
: juce::String(),
Expand Down
4 changes: 3 additions & 1 deletion app/RhythmPane.h
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ class RhythmSwitch final : public juce::Component
// The RHYTHM screen's eight fields, one control each, in the screen's order:
// the choices above, the numbers below. Every control carries its field's
// name as its component id (pattern, kit, beat, variation, level, reverb,
// toneLow, toneHigh).
// toneLow, toneHigh). Two of them lock themselves from the memory's own
// facts, caption saying why: BEAT under a recorded take, PATTERN at a beat
// whose list is not charted (Rhythm.hpp) — there it shows the number.
class RhythmControls final : public juce::Component
{
public:
Expand Down
11 changes: 9 additions & 2 deletions app/SlotInspector.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#include "SlotInspector.h"

#include "RefusalWords.h"
#include "SlotTable.h"
#include "Strings.h"

Expand Down Expand Up @@ -124,7 +125,11 @@ void SlotInspector::refresh()
nameEditor_.setReadOnly(!can.rename);
tempoEditor_.setEnabled(live && can.tempo);
tempoEditor_.setReadOnly(!can.tempo);
countIn_.setEnabled(live && can.countIn);
// A click the core would refuse is not offered: where the count-in's
// switch would write a PATTERN number at a beat whose list is not
// charted (CountIn.hpp, #149), the card is a lamp and its orange line
// says why, before the click rather than in a banner after it.
countIn_.setEnabled(live && can.countIn && !info_.countInRefused);
oneShot_.setEnabled(live && can.oneShot);
playStop_.setEnabled(live && can.playStop);
footer_.setText(can.anyWrite() ? juce::String("Disconnect to hear the changes.")
Expand Down Expand Up @@ -155,7 +160,9 @@ void SlotInspector::refresh()
info_.countIn ? "One bar of count at " + SlotTable::formatTempo(info_.tempoTenths)
+ " BPM, then the loop."
: "No count: the loop starts the moment you press play.",
!info_.countIn && info_.countInTakesPattern
info_.countInRefused
? words::countInRefused(*info_.countInRefused, info_.rhythm.beat)
: !info_.countIn && info_.countInTakesPattern
? juce::String::fromUTF8("Switching it on replaces a rhythm pattern chosen on the "
"pedal.")
: juce::String());
Expand Down
27 changes: 23 additions & 4 deletions app/SlotTable.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

#include "SlotTable.h"

#include "RefusalWords.h"
#include "Strings.h"

#include <felitronics/appkit/Brand.h>
Expand Down Expand Up @@ -292,8 +293,10 @@ void SlotTable::cellClicked(int row, int columnId, const juce::MouseEvent& e)
onOneShotToggled(slotOfRow(row));
return;
}
// Same gesture for the Count-In cell.
if (columnId == kCountIn && onCountInToggled && slotOfRow(row) > 0 && allowed().countIn) {
// Same gesture for the Count-In cell — unless the core would refuse the
// click (CountIn.hpp, #149): then the dot is a lamp, and its hover says why.
if (columnId == kCountIn && onCountInToggled && slotOfRow(row) > 0 && allowed().countIn
&& !rows_[static_cast<std::size_t>(row)].info.countInRefused) {
onCountInToggled(slotOfRow(row));
return;
}
Expand All @@ -305,6 +308,19 @@ void SlotTable::cellClicked(int row, int columnId, const juce::MouseEvent& e)
}
}

// The sentence a seven-pixel dot cannot carry: hovering the Count-In cell of
// a memory whose click the core would refuse says why, in the card's words
// (RefusalWords.h). Every other cell says nothing.
juce::String SlotTable::getCellTooltip(int row, int columnId)
{
if (columnId != kCountIn || slotOfRow(row) == 0)
return {};
const SlotRow& r = rows_[static_cast<std::size_t>(row)];
if (!r.info.countInRefused)
return {};
return words::countInRefused(*r.info.countInRefused, r.info.rhythm.beat);
}

// --- per-row busy indication ---

void SlotTable::setBusySlot(int slot)
Expand Down Expand Up @@ -532,9 +548,12 @@ void SlotTable::paintCell(juce::Graphics& g, int row, int columnId, int width, i
if (columnId == kOneShot || columnId == kCountIn) {
// The cell is the toggle: filled = on, hollow = off (click flips it).
// On a card this app only reads it is a lamp, not a button: the
// same dot, dimmed, and a click does nothing.
// same dot, dimmed, and a click does nothing. So is a Count-In dot
// whose click the core would refuse (CountIn.hpp, #149).
const bool on = columnId == kOneShot ? r.info.oneShot : r.info.countIn;
const bool clickable = columnId == kOneShot ? allowed().oneShot : allowed().countIn;
const bool clickable = columnId == kOneShot
? allowed().oneShot
: allowed().countIn && !r.info.countInRefused;
const float d = 7.0f;
const float x = static_cast<float>(area.getX()) + 2.0f;
const float y = (static_cast<float>(height) - d) * 0.5f;
Expand Down
1 change: 1 addition & 0 deletions app/SlotTable.h
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ class SlotTable final : public juce::Component,
void selectedRowsChanged(int lastRowSelected) override;
void cellDoubleClicked(int row, int columnId, const juce::MouseEvent&) override;
void cellClicked(int row, int columnId, const juce::MouseEvent&) override;
juce::String getCellTooltip(int row, int columnId) override;
juce::var getDragSourceDescription(const juce::SparseSet<int>& selectedRows) override;

// Internal row drag (the swap gesture). File drags use the interface above.
Expand Down
10 changes: 9 additions & 1 deletion core/include/loopercat/Catalog.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
#include "usecases/PlayStop.hpp"
#include "usecases/Rhythm.hpp"

#include <optional>
#include <string>
#include <utility>
#include <vector>
Expand Down Expand Up @@ -66,6 +67,11 @@ struct SlotInfo {
// thing the UI has to say out loud beforehand.
// A bool, not the pattern: its names are not
// hardware-verified yet, so we cannot name it.
std::optional<usecases::countin::Refusal> countInRefused; // the next click on Play
// Count-In (the opposite of countIn) would write a
// PATTERN number at a beat whose list is not charted
// (CountIn.hpp, #149): the switch is a lamp, and this
// is why. Nothing when the click is the player's.
long long tempoTenths; // Tempo: tenths of BPM the pedal will play at
long long measures; // MeasLen: whole bars, as the pedal displays them
long long recTempoTenths; // RecTmp: tenths of BPM the take was recorded at —
Expand Down Expand Up @@ -103,13 +109,15 @@ inline SlotInfo readSlot(std::string_view memoryText, const profile::DeviceProfi
for (int track = 1; track <= family.trackCount; ++track)
tracks.push_back(readTrack(body, track));
const TrackInfo& first = tracks.front();
const bool countIn = usecases::countin::isOn(body);
return { slot,
rc0::decodeName(body),
first.hasAudio,
first.frames,
first.oneShot,
usecases::countin::isOn(body),
countIn,
usecases::countin::patternAtRisk(body).has_value(),
usecases::countin::refusal(body, !countIn),
rc0::sectionField(body, rc0::kSectionMaster, "Tempo"),
first.measures,
first.recTempoTenths,
Expand Down
Loading
Loading