Status: Historical — shipped; this is the plan of record, not edited further. Decision record: ADR-0010. Tracks #8 (G6): Rebus entry — editable multi-letter cells. Companion doc to
ARCHITECTURE.md. The feature is live (Rebus key + long-press +Esc, first-letter acceptance, autoshrink rendering); see “Feature: solve” inARCHITECTURE.mdfor the as-built summary.
Let a solver type more than one letter into a single grid cell so puzzles with rebus squares (e.g. a “THE” or “EST” cell) can actually be solved from the UI. Stay consistent with existing solve mechanics (focus advance, check/reveal, autosave, sync, completion detection) and stay discoverable for users who have never met a rebus before.
What already works:
puz_parser.dart and ipuz_parser.dart both decode rebus
squares from GRBS/RTBL (and the ipuz map form) into
SolutionCell.solution as a multi-character string (e.g. "EST").
Covered by puz_parser_test.dart and ipuz_parser_test.dart.CellProgress.letter is already a String — no
schema change is needed to hold a multi-letter answer. The DB row
(Drift JSON serialization of Grid<CellProgress>) also already
stores it as a string.EntryMode { normal, pencil, rebus } is defined but
currently unused at runtime.SolveNotifier.inputRebus(value) exists, normalizes to
uppercase A–Z, requires length >= 2, writes one cell, advances
focus once (same as a single letter), schedules save, runs the
completion check. Covered by solve_notifier_test.dart.crossword_grid_input.dart has an “Enter rebus”
item that opens an AlertDialog with a TextField. This works but
is the only entry point and almost no first-time user will find
it._checkCompletion() already compares whole strings
(prog.letter.toUpperCase() == cell.solution.toUpperCase()), so
multi-char answers complete the puzzle correctly today.GridProgressMutator.checkCells compares whole
strings; revealCells writes the full solution string. Both work
for rebus today.What is missing:
CrosswordGridPainter._paintLetter uses a fixed
font-size factor with clamp(10, 32) and a maxWidth of one cell
width. Multi-character strings render but clip / look cramped — no
autoshrink, no fitting.CrosswordKeyboard has no
way to open the rebus dialog; soft-keyboard-only users (the iOS/
Android default) cannot use the feature at all.backspace() call clears the entire letter field. Reasonable, but
undocumented and worth confirming behavior matches user mental
model.Guiding tradeoffs, in priority order:
1`.
_showRebusDialog UI, reachable from at least: long-press menu
(today), a soft-keyboard “Rebus” key, and a physical-keyboard
shortcut. All three call the same method, identical normalization.The NYT pattern, as described in their own explainer:
Esc. Inside the dialog, Enter/Return
saves; Esc cancels.Two takeaways for our design:
…. The … users
may remember on Android is the layer toggle, not the rebus
button.CrosswordKeyboard is single-layer (alphabet only). A two-tap
path would be friction without benefit.Decision: put a single, always-visible key labeled “Rebus” on the bottom row of our soft keyboard. One tap opens the dialog. This is strictly fewer taps than NYT, uses NYT’s actual visible label so NYT-trained users recognize it, and keeps the principle-3.1 “don’t spoil” property (the key is on every puzzle so its presence leaks nothing).
Keyboard layout change. Current bottom row:
⌫ Z X C V B N M ✓
Proposed bottom row (insert a “Rebus” _SpecialKey to the right of
✓ so the rebus key is the bottom-right-most element, matching
NYT’s bottom-right convention):
⌫ Z X C V B N M ✓ Rebus
The Rebus key is wider than a letter key (matches _SpecialKey’s
existing unit * 1.3 width to fit the four-letter label). ✓
(check word) stays — it’s a Crosscue-specific affordance NYT
doesn’t have. The seven bottom-row letter keys narrow slightly; this
is the lowest-density row so the impact is acceptable.
If user testing shows the layout is too crowded, fall back to placing
“Rebus” immediately right of ⌫:
⌫ Rebus Z X C V B N M ✓
Either way, the key is present on every puzzle.
Wiring. Add VoidCallback onRebus to CrosswordKeyboard (no
visibility flag) and route the tap through the same shared dialog
helper used by long-press and the physical-keyboard shortcut.
Today’s “Enter rebus” item in the long-press popup stays exactly as-is. It is the discoverable surface for power users and the only surface on desktop where there’s no soft keyboard.
Use Esc to open the rebus dialog on the current focused cell —
matching NYT’s web shortcut exactly. Pressing Esc inside the
dialog cancels (also matching NYT). The dual role of Esc is
internally consistent: “switch into rebus mode” on the grid, “switch
out of rebus mode” in the dialog.
Implement in _onKeyEvent: branch on LogicalKeyboardKey.escape →
call the shared showRebusDialogForFocus helper.
Today’s AlertDialog is functional. Aligning with NYT conventions:
inputLetter() (so the cell behaves
identically to a normal entry); two or more → call inputRebus().
This is the only place we round-trip between the two entry methods.Enter/Cancel buttons; Esc
cancels (Flutter default for AlertDialog). Optionally also
commit-and-close on tap-outside (barrierDismissible: true with
a WillPopScope that submits the current text) to match NYT’s
“tap anywhere inside the grid to close and save” behavior. Defer
this until after the buttons ship — the buttons are more
discoverable for first-time users.CrosswordGridPainter._paintLetter needs to fit multi-character text
in a single cell without changing cell size. Change:
letter.length > 1, scale font size down by
min(1.0, cellSize * factor / measuredWidth) so the glyphs fit the
cell width minus a small padding. Floor at 8.0 to remain legible —
if even 8.0 doesn’t fit (5–6 letter rebus in a 7×7 mini), fall back
to a 7.0 floor and accept slight crowding.bold to w600 for length >= 3 to
visually relieve density.checkedCorrect,
checkedIncorrect, and revealed exactly like single-letter cells.Edge case: the entry animation (scale: 0.7 + 0.3 * effectValue)
multiplies into the autoshrink. Use the autoshrunk font size as the
base, then apply the animation scale, so multi-letter cells still
animate proportionally without overflowing at peak.
Important finding from the NYT article: NYT accepts either the full rebus string or just its first letter as a valid answer. From the JACK example:
“JACK works for both the Across and Down entries, and the following rebus answers would be accepted: JACK, J (The first letter)”
This is a deliberate accessibility choice: a solver who never discovers rebus mode can still complete the puzzle. The cluing still works (“LUMBERJ” makes no sense, but the grid is accepted as solved).
Recommendation: adopt the same rule. Three reasons:
_checkCompletion in solve_notifier.dart and checkCells in
grid_progress_mutator.dart. No model changes.Implementation sketch. Replace strict equality in the two checking sites with a helper:
bool _matchesSolution(String entered, String solution) {
if (entered.isEmpty) return false;
final e = entered.toUpperCase();
final s = solution.toUpperCase();
if (e == s) return true;
// Rebus cells: first letter alone also counts.
if (s.length > 1 && e.length == 1 && e == s[0]) return true;
return false;
}
Used in _checkCompletion (completion) and in checkCells (the
check-letter / check-word actions). Reveal stays as-is — revealing
writes the full canonical answer.
Implications:
checkedCorrect, matching the completion rule.status.isTerminal, which fires once _checkCompletion passes).backspace() on a multi-letter cell clears the whole
cell (current behavior). This matches how the cell appears: one
visual unit.Goal: existing rebus entries (via long-press) render correctly.
crossword_grid_painter.dart
_fitFontSize(letter, cellSize) returning
a font size that fits the cell._paintLetter and in the backspace-fade branch."EST" at three cell sizes (small mini, medium, large) and asserts
no clipping.crossword_keyboard.dart
VoidCallback onRebus constructor param (no
visibility flag — the key is always shown, matching NYT)._SpecialKey to the right of ✓ in the bottom
row. Width matches the existing unit * 1.3 used by ⌫ / ✓.
Pick a neutral background (xwTheme.keyDefault so it reads as
“extra letter input” rather than as a destructive or affirmative
action — distinct from both ⌫ and ✓).solve_screen.dart
_showRebusDialog (currently on _CrosswordGridState as a
private helper) into a shared top-level helper
showRebusDialogForFocus(BuildContext, WidgetRef, puzzleId) that
reads the current focus from the notifier, dispatches the dialog,
and routes the result through inputLetter / inputRebus per
§4.4. Call it from both the long-press menu path and the new
keyboard onRebus callback.Note: a
hasRebusSquaresderived state onSolveStateis not needed under the NYT-style always-visible rule. We avoid the derived getter entirely.
solve_notifier.dart
inputRebus: if input length is 1, delegate to
inputLetter (preserves dialog → normal-entry round-trip).upper.substring(0, min(upper.length, 6)))._checkCompletion with the
_matchesSolution helper (§4.6).grid_progress_mutator.dart
_matchesSolution helper in checkCells so the
check-letter / check-word actions agree with completion logic on
rebus cells.inputRebus: empty (no-op), single-char (delegates to inputLetter),
multi-char (today’s behavior), over-cap (truncates)."J" in a "JACK" cell (per §4.6).checkCells marks "J" in a "JACK" cell as checkedCorrect.crossword_grid_input.dart::_onKeyEvent
LogicalKeyboardKey.escape, call the shared
showRebusDialogForFocus helper (matches NYT web). The dialog
itself uses Flutter’s default Esc-cancels behavior, so the
dual-role mapping just works.ARCHITECTURE.md: under “Feature: solve”, mention rebus entry
surfaces (keyboard “Rebus” key, long-press, Esc). ✅ done.solve_notifier_test.dart: cover the empty / 1-char / >6-char
paths of inputRebus.crossword_keyboard_test.dart (new): rebus key is present and its
tap fires the callback. (As-built: the key is always shown — there
is no showRebus visibility flag, per §4.1 and the Phase B note.)solve_state_test.dart (or wherever derived state is tested): add
a hasRebusSquares case for a puzzle with and without a rebus cell.hasRebusSquares derived getter unnecessary, so
neither the getter nor this test was added.solve_screen_widget_test.dart: rebus key appears only for the
rebus3x3 fixture, not for the plain fixture.| File | Change |
|---|---|
lib/features/solve/presentation/widgets/crossword_grid_painter.dart |
_fitFontSize helper; use in _paintLetter. |
lib/features/solve/presentation/widgets/crossword_keyboard.dart |
New required onRebus; insert “Rebus” _SpecialKey to the right of ✓; recompute bottom-row width math. |
lib/features/solve/presentation/widgets/crossword_grid_input.dart |
Pre-fill dialog with single-char letters; route 1-char input through inputLetter; cap at 6; add Esc shortcut in _onKeyEvent. |
lib/features/solve/presentation/screens/solve_screen.dart |
Pass onRebus into CrosswordKeyboard; lift _showRebusDialog into a shared showRebusDialogForFocus helper used by all three surfaces (long-press, keyboard Rebus key, Esc). |
lib/features/solve/presentation/notifiers/solve_notifier.dart |
inputRebus delegates on 1-char input; clamps at 6 chars; _checkCompletion uses _matchesSolution to accept first-letter answers. |
lib/features/solve/domain/services/grid_progress_mutator.dart |
checkCells uses the same _matchesSolution helper so check/letter feedback agrees with completion. |
test/... |
See Phase E. |
ARCHITECTURE.md |
One paragraph under “Feature: solve”. |
No changes to:
CellProgress, SolutionCell, EntryMode).CellProgress.letter is already String).docs/architecture/sync-design.md).Esc. ✅Esc opens the rebus dialog;
Esc inside the dialog cancels (NYT web pattern). ✅inputLetter; an empty submission is a no-op; the dialog is never
a dead end. ✅SolutionCell. As-built: a lightweight form shipped —
SolutionCell.accepts recognizes a "/"-delimited pair (e.g.
"PB/AU") without a schema change. See ARCHITECTURE.md → “Feature:
solve”. A full per-direction model remains out of scope._letterFilterRe strips non-A–Z; keep that for now. The puzzle
file formats already preserve arbitrary strings in SolutionCell.
solution, so widening later is non-breaking.