fix(app): furigana stays on its word, and readings typed as learners type

Furigana:
- Text typed after a word with a reading no longer takes the reading.
- A reading over text formatted in parts (a bold kanji) is drawn once,
  over all of it, each part in its own style; in Markdown it is one
  <ruby>, the parts keeping their marks, the reading escaped.
- Readings follow the editor's text size, not the system's, and a
  linked word with a reading still opens its link.
- Anki's <ruby> keeps the formatting inside it.
- The Markdown export escapes what Markdown would read as its syntax
  and keeps blank lines inside code blocks.

Typing a reading:
- Hepburn as learners type it: onna is おんな, konnichiwa こんにちは, m
  before b, p and m is ん (shimbun); keyboards' n' and nnn still work.
- The caret stays where the person types, also in the middle.
- Macrons count both ways (ō as おう or おお); a card listing several
  readings (いく、ゆく) takes any of them; only words written with kanji
  ask for their reading.
- No autocorrect, suggestions or curly quotes in the field; Done with
  nothing typed keeps the keyboard; the lock screen lets go of the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 12:45:19 +02:00
commit d5e5945d93
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
11 changed files with 468 additions and 101 deletions

View file

@ -29,61 +29,116 @@ String richContentToMarkdown(
required String? Function(String assetId) linkTo,
}) {
final document = content.toDocument();
_formulasInDollars(document.root);
_rubiesInHtml(document.root);
return documentToMarkdown(
document,
customParsers: [
_ImageParser(linkTo, texts.unavailable),
_SoundParser(linkTo, texts.sound, texts.unavailable),
_YoutubeParser(texts.video),
const _MathParser(),
_CalloutParser(texts.calloutName),
_SpoilerParser(texts.spoiler),
],
// A blank line between blocks: Markdown runs lines without one into
// one paragraph.
lineBreak: '\n',
).replaceAll(RegExp(r'\n{3,}'), '\n\n').trim();
_textForMarkdown(document.root);
return _withoutExtraBlankLines(
documentToMarkdown(
document,
customParsers: [
_ImageParser(linkTo, texts.unavailable),
_SoundParser(linkTo, texts.sound, texts.unavailable),
_YoutubeParser(texts.video),
const _MathParser(),
_CalloutParser(texts.calloutName),
_SpoilerParser(texts.spoiler),
],
// A blank line between blocks: Markdown runs lines without one into
// one paragraph.
lineBreak: '\n',
),
).trim();
}
/// Writes every formula within a line under [node] as its LaTeX between
/// dollars, as Markdown's maths does.
void _formulasInDollars(Node node) {
if (node.delta case final delta?
when delta.any((op) => op.attributes?[inlineMathKey] == true)) {
/// [markdown] with no more than one blank line between blocks, as the
/// encoder leaves some; inside a fenced code block every line stays.
String _withoutExtraBlankLines(String markdown) {
final parts = markdown.split('```');
return [
for (final (i, part) in parts.indexed)
if (i.isEven) part.replaceAll(RegExp(r'\n{3,}'), '\n\n') else part,
].join('```');
}
/// Prepares the text of every line under [node] for Markdown: what
/// Markdown would take as its own syntax escaped (`*`, `_`, `<`, a number
/// and a full stop starting a line, …), formulas as their LaTeX between
/// dollars, and furigana as HTML's ruby, which most Markdown viewers show
/// (`<ruby>漢字<rt>かんじ</rt></ruby>`): once around a run of text with one
/// reading, its pieces keeping their bold, links and the like.
void _textForMarkdown(Node node) {
if (node.delta case final delta?) {
final ops = delta.toList();
final written = Delta();
for (final op in delta) {
if (op is TextInsert && op.attributes?[inlineMathKey] == true) {
for (var i = 0; i < ops.length; i++) {
final op = ops[i];
if (op is! TextInsert) {
written.add(op);
continue;
}
final attributes = op.attributes;
if (attributes?[inlineMathKey] == true) {
written.insert('\$${op.text}\$');
} else {
written.add(op);
continue;
}
final reading = attributes?[rubyKey];
if (reading is! String) {
written.insert(
_escaped(op.text, attributes, i == 0),
attributes: attributes,
);
continue;
}
written.insert('<ruby>');
for (; i < ops.length; i++) {
final piece = ops[i];
if (piece is! TextInsert || piece.attributes?[rubyKey] != reading) {
break;
}
final rest = {...?piece.attributes}..remove(rubyKey);
written.insert(
_escaped(piece.text, piece.attributes, false),
attributes: rest.isEmpty ? null : rest,
);
}
i--;
written.insert('<rt>${_htmlEscaped(reading)}</rt></ruby>');
}
node.updateAttributes({'delta': written.toJson()});
}
node.children.forEach(_formulasInDollars);
node.children.forEach(_textForMarkdown);
}
/// Writes every text with furigana under [node] as HTML's ruby, which most
/// Markdown viewers show: `<ruby>漢字<rt>かんじ</rt></ruby>`.
void _rubiesInHtml(Node node) {
if (node.delta case final delta?
when delta.any((op) => op.attributes?[rubyKey] is String)) {
final written = Delta();
for (final op in delta) {
if (op case TextInsert(:final text, :final attributes?)
when attributes[rubyKey] is String) {
written.insert('<ruby>$text<rt>${attributes[rubyKey]}</rt></ruby>');
} else {
written.add(op);
}
}
node.updateAttributes({'delta': written.toJson()});
/// [text] with what Markdown would read as its syntax escaped by a
/// backslash; code as it is, as Markdown doesn't read inside it. [first]:
/// the text starts its line, where a number with a full stop, `-`, `+`
/// and `#` begin a list or heading.
String _escaped(String text, Attributes? attributes, bool first) {
if (attributes?[AppFlowyRichTextKeys.code] == true) {
return text;
}
node.children.forEach(_rubiesInHtml);
var escaped = text.replaceAllMapped(
RegExp(r'[\\`*_\[\]<>|~$]'),
(match) => '\\${match[0]}',
);
if (first) {
escaped = escaped
.replaceFirstMapped(
RegExp(r'^(\s*\d+)([.)])'),
(m) => '${m[1]}\\${m[2]}',
)
.replaceFirstMapped(
RegExp(r'^(\s*)([-+#])'),
(m) => '${m[1]}\\${m[2]}',
);
}
return escaped;
}
/// [text] for inside an HTML element.
String _htmlEscaped(String text) => text
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
class const _ImageParser(
final String? Function(String assetId) _linkTo,
final String _unavailable,

View file

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:material_ui/material_ui.dart';
@ -38,49 +39,144 @@ Future<void> editRuby(BuildContext context, EditorState editorState) async {
/// [editing], the text it reads dotted underneath, as the caret moves
/// through it as text; else the reading set above the text, smaller, the
/// text on the line's baseline.
///
/// A reading belongs to a run of text: pieces formatted apart within it (a
/// bold kanji) share the one reading, drawn once over all of them, each
/// piece in its own style. Typing at a run's end doesn't extend it.
TextSpanDecoratorForAttribute withRuby(
TextSpanDecoratorForAttribute base, {
required bool editing,
}) => (context, node, index, text, before, after) {
final span = base(context, node, index, text, before, after);
final reading = text.attributes?[rubyKey];
if (reading is! String || reading.isEmpty || span is! TextSpan) {
return span;
}
final style = span.style ?? before.style ?? const TextStyle();
if (editing) {
return TextSpan(
text: span.text,
children: span.children,
style: style.copyWith(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
decorationColor: Theme.of(context).colorScheme.primary,
}) {
_endRubyWithItsText();
return (context, node, index, text, before, after) {
final span = base(context, node, index, text, before, after);
final reading = text.attributes?[rubyKey];
if (reading is! String || reading.isEmpty || span is! TextSpan) {
return span;
}
final style = span.style ?? before.style ?? const TextStyle();
if (editing) {
return TextSpan(
text: span.text,
children: span.children,
style: style.copyWith(
decoration: TextDecoration.underline,
decorationStyle: TextDecorationStyle.dotted,
decorationColor: Theme.of(context).colorScheme.primary,
),
recognizer: span.recognizer,
mouseCursor: span.mouseCursor,
semanticsLabel: span.semanticsLabel,
locale: span.locale,
);
}
final run = _runAt(node.delta, index, reading);
if (run == null) {
// Drawn with the run's first piece.
return const TextSpan();
}
final onTap = switch (span.recognizer) {
final TapGestureRecognizer tap => tap.onTap,
_ => null,
};
final ruby = RubyText(
base: TextSpan(
children: [
for (final (i, piece) in run.indexed)
TextSpan(
text: piece.text,
style: i == 0 ? style : _styled(style, piece.attributes),
),
],
),
recognizer: span.recognizer,
mouseCursor: span.mouseCursor,
semanticsLabel: span.semanticsLabel,
locale: span.locale,
reading: reading,
style: style,
);
}
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
child: RubyText(text: text.text, reading: reading, style: style),
);
};
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
baseline: TextBaseline.alphabetic,
// A linked word with a reading still opens its link.
child: onTap == null
? ruby
: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(onTap: onTap, child: ruby),
),
);
};
}
/// [text] with its [reading] set above it, smaller and centred, the text in
/// [style] on the baseline of the line around it.
/// Lets text typed right after a reading's text go without its reading,
/// as after a link: the editor otherwise gives typed text the attributes
/// of the text before it.
void _endRubyWithItsText() {
if (!AppFlowyRichTextKeys.partialSliced.contains(rubyKey)) {
AppFlowyRichTextKeys.partialSliced.add(rubyKey);
}
}
/// The pieces of text with [reading] that start at [index] in [delta], when
/// the piece there is the first of them; null when one before it has the
/// reading too.
List<TextInsert>? _runAt(Delta? delta, int index, String reading) {
if (delta == null) {
return null;
}
final run = <TextInsert>[];
var offset = 0;
TextInsert? previous;
for (final op in delta) {
if (op is! TextInsert) {
offset += op.length;
previous = null;
continue;
}
if (offset == index) {
if (previous?.attributes?[rubyKey] == reading) {
return null;
}
run.add(op);
} else if (run.isNotEmpty) {
if (op.attributes?[rubyKey] != reading) {
break;
}
run.add(op);
}
offset += op.length;
previous = op;
}
return run.isEmpty ? null : run;
}
/// [style] with the formatting [attributes] give a piece of text: bold,
/// italic, underlined or struck through.
TextStyle _styled(TextStyle style, Attributes? attributes) => style.copyWith(
fontWeight: attributes?[AppFlowyRichTextKeys.bold] == true
? FontWeight.bold
: null,
fontStyle: attributes?[AppFlowyRichTextKeys.italic] == true
? FontStyle.italic
: null,
decoration: TextDecoration.combine([
if (attributes?[AppFlowyRichTextKeys.underline] == true)
TextDecoration.underline,
if (attributes?[AppFlowyRichTextKeys.strikethrough] == true)
TextDecoration.lineThrough,
]),
);
/// [base] with its [reading] set above it, smaller and centred, the text
/// on the baseline of the line around it, [style] the line's. Neither
/// follows the system's text scale, as the editor's own text doesn't.
class const RubyText({
required final String text,
required final InlineSpan base,
required final String reading,
required final TextStyle style,
super.key,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Semantics(
label: '$text ($reading)',
label: '${base.toPlainText()} ($reading)',
excludeSemantics: true,
// Laid out from the bottom: the text comes first, so the box takes its
// baseline, and the line's text stays level with it.
@ -88,12 +184,15 @@ class const RubyText({
mainAxisSize: MainAxisSize.min,
verticalDirection: VerticalDirection.up,
children: [
Text(text, style: style),
Text.rich(base, style: style, textScaler: TextScaler.noScaling),
Text(
reading,
textScaler: TextScaler.noScaling,
style: style.copyWith(
fontSize: (style.fontSize ?? 16) * _rubyScale,
height: 1.1,
fontWeight: FontWeight.normal,
decoration: TextDecoration.none,
),
),
],

View file

@ -19,7 +19,7 @@ const _romaji = {
'sya': 'しゃ', 'syu': 'しゅ', 'syo': 'しょ',
'cha': 'ちゃ', 'chu': 'ちゅ', 'cho': 'ちょ', 'chi': 'ち', 'che': 'ちぇ',
'tya': 'ちゃ', 'tyu': 'ちゅ', 'tyo': 'ちょ',
'tsu': 'つ', 'nya': 'にゃ', 'nyu': 'にゅ', 'nyo': 'にょ',
'tsu': 'つ', 'dzu': 'づ', 'nya': 'にゃ', 'nyu': 'にゅ', 'nyo': 'にょ',
'hya': 'ひゃ', 'hyu': 'ひゅ', 'hyo': 'ひょ',
'bya': 'びゃ', 'byu': 'びゅ', 'byo': 'びょ',
'pya': 'ぴゃ', 'pyu': 'ぴゅ', 'pyo': 'ぴょ',
@ -49,7 +49,7 @@ const _romaji = {
'la': 'ら', 'li': 'り', 'lu': 'る', 'le': 'れ', 'lo': 'ろ',
'wa': 'わ', 'wi': 'うぃ', 'we': 'うぇ', 'wo': 'を',
'va': 'ゔぁ', 'vi': 'ゔぃ', 'vu': 'ゔ', 've': 'ゔぇ', 'vo': 'ゔぉ',
'nn': 'ん', "n'": 'ん',
"n'": 'ん', 'n’': 'ん',
'xa': 'ぁ', 'xi': 'ぃ', 'xu': 'ぅ', 'xe': 'ぇ', 'xo': 'ぉ',
// One letter.
'a': 'あ', 'i': 'い', 'u': 'う', 'e': 'え', 'o': 'お', '-': 'ー',
@ -57,15 +57,30 @@ const _romaji = {
/// [text] with the romaji typed in it turned into hiragana as far as they
/// spell kana (`taberu` is `たべる`): a doubled consonant is a small っ
/// (`kitte`), `n` before a consonant ん. What can still become more kana is
/// left as typed (`tab` is `たb`, a last `n` stays `n`), unless [finished].
/// Kana and anything else stay as they are.
/// (`kitte`), `n` before a consonant ん. A doubled `n` before a vowel or
/// `y` is ん and the n-row, as Hepburn writes it (`onna` is おんな,
/// `konnichiwa` こんにちは); ん before a vowel is `n'` (`kin'en`), or `nn`
/// typed once more (`konnnichiha`), as keyboards spell it. What can still
/// become more kana is left as typed (`tab` is `たb`, a last `n` or `nn`
/// stays), unless [finished]. Kana and anything else stay as they are.
String romajiToHiragana(String text, {bool finished = false}) {
final out = StringBuffer();
final lower = text.toLowerCase();
var i = 0;
while (i < lower.length) {
final rest = lower.substring(i);
if (rest.startsWith('nn')) {
final after = rest.length > 2 ? rest[2] : null;
if (after == null && !finished) {
// The next letter decides: おんな or おんあ.
out.write(text.substring(i));
break;
}
out.write('ん');
// Before a vowel or y the second n starts the next kana.
i += after != null && 'aiueoy'.contains(after) ? 1 : 2;
continue;
}
String? kana;
var length = 0;
for (final size in const [3, 2, 1]) {
@ -82,8 +97,10 @@ String romajiToHiragana(String text, {bool finished = false}) {
}
final char = rest[0];
final next = rest.length > 1 ? rest[1] : null;
if (char == 'n' && next != null && !"aiueoyn'".contains(next)) {
// ん before another consonant.
if ((char == 'n' && next != null && !"aiueoyn'’".contains(next)) ||
(char == 'm' && next != null && 'bpm'.contains(next))) {
// ん before another consonant, written m before b, p and m as
// Hepburn does (`shimbun`, `tempura`).
out.write('ん');
i++;
} else if (next == char && 'bcdfghjkmpqrstvwxyz'.contains(char)) {
@ -106,9 +123,9 @@ String romajiToHiragana(String text, {bool finished = false}) {
}
/// Turns romaji into hiragana as they are typed, for answers in kana on a
/// keyboard without Japanese: what can't be kana yet stays as typed, the
/// caret at the end. Text being composed by the device's own Japanese input
/// is left to it.
/// keyboard without Japanese: what can't be kana yet stays as typed, and
/// the caret stays where the person types, also in the middle. Text being
/// composed by the device's own Japanese input is left to it.
class const KanaInputFormatter() extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
@ -118,13 +135,47 @@ class const KanaInputFormatter() extends TextInputFormatter {
if (newValue.composing.isValid && !newValue.composing.isCollapsed) {
return newValue;
}
final kana = romajiToHiragana(newValue.text);
if (kana == newValue.text) {
final text = newValue.text;
final caret = newValue.selection.isValid && newValue.selection.isCollapsed
? newValue.selection.baseOffset.clamp(0, text.length)
: text.length;
// What is before the caret and after it, each on its own: the romaji
// typed is right before the caret, and the caret goes after its kana.
final before = romajiToHiragana(text.substring(0, caret));
final after = romajiToHiragana(text.substring(caret));
if (before + after == text) {
return newValue;
}
return TextEditingValue(
text: kana,
selection: TextSelection.collapsed(offset: kana.length),
text: before + after,
selection: TextSelection.collapsed(offset: before.length),
);
}
}
/// The ways [typed] may be read as kana, finished: long vowels with
/// macrons spelt both ways (`ō` as `ou` and as `oo`), so `tōkyō` reads
/// とうきょう and `ōkii` おおきい; katakana as hiragana, without spaces.
Set<String> typedReadings(String typed) => {
for (final long in const [
{'ā': 'aa', 'ī': 'ii', 'ū': 'uu', 'ē': 'ei', 'ō': 'ou'},
{'ā': 'aa', 'ī': 'ii', 'ū': 'uu', 'ē': 'ee', 'ō': 'oo'},
])
readingKey(
romajiToHiragana(
[for (final char in typed.split('')) long[char] ?? char].join(),
finished: true,
),
),
};
/// [reading] as readings are compared: in hiragana, without spaces.
String readingKey(String reading) =>
katakanaToHiragana(reading).replaceAll(RegExp(r'\s+'), '');
/// The readings a card's reading field gives, one or several (`いく、ゆく`),
/// as compared ([readingKey]).
Set<String> givenReadings(String field) => {
for (final reading in field.split(RegExp('[、,,;;//]')))
if (readingKey(reading).isNotEmpty) readingKey(reading),
};

View file

@ -173,10 +173,11 @@ class _Field(
}
}
/// A ruby: each base text with its reading (`<rt>`) as furigana on it;
/// the parentheses for where rubies aren't shown (`<rp>`) left out.
/// A ruby: each base text with its reading (`<rt>`) as furigana on it,
/// keeping what it is marked with inside (`<b>` on a cloze's answer); the
/// parentheses for where rubies aren't shown (`<rp>`) left out.
void _ruby(dom.Element ruby) {
final base = StringBuffer();
final base = <dom.Node>[];
for (final node in ruby.nodes) {
if (node is dom.Element && node.localName == 'rt') {
final reading = node.text.trim();
@ -184,14 +185,14 @@ class _Field(
if (reading.isNotEmpty) {
_attributes = {..._attributes, rubyKey: reading};
}
_text(base.toString());
_visit(base);
_attributes = outer;
base.clear();
} else if (node is! dom.Element || node.localName != 'rp') {
base.write(node.text);
base.add(node);
}
}
_text(base.toString());
_visit(base);
}
void _list(dom.Element list, String type) {

View file

@ -465,9 +465,14 @@ class StudySessionBloc(
!_options.quizVocabulary &&
card.content.type == CardType.vocabulary &&
card.content.reading.trim().isNotEmpty &&
// A word written in kana (テレビ) is its own reading.
_kanji.hasMatch(card.content.front.plainText) &&
!card.content.usuallyKana &&
!_asksBack(card);
/// A Han character, as kanji are written.
static final _kanji = RegExp(r'[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF]');
/// How far ahead a card in its learning steps comes early, once nothing
/// else is due in the round.
static const _learnAhead = Duration(minutes: 20);

View file

@ -358,13 +358,11 @@ class _TypedReadingBodyState() extends State<TypedReadingBody> {
super.dispose();
}
/// What was typed as a reading: in hiragana, without spaces, a last `n`
/// as ん.
String get _answer => _normalized(_typed.text);
static String _normalized(String reading) => katakanaToHiragana(
romajiToHiragana(reading, finished: true),
).replaceAll(RegExp(r'\s+'), '');
/// Whether what was typed is the card's reading, or one of them: read
/// in each way it can be ([typedReadings]).
bool get _right => typedReadings(
_typed.text,
).any(givenReadings(widget.card.content.reading).contains);
void _check() {
if (_typed.text.trim().isNotEmpty && !widget.checked) {
@ -377,7 +375,7 @@ class _TypedReadingBodyState() extends State<TypedReadingBody> {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
final content = widget.card.content;
final right = _answer == _normalized(content.reading);
final right = _right;
final mark = right ? theme.extras.success : theme.colorScheme.error;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@ -403,12 +401,20 @@ class _TypedReadingBodyState() extends State<TypedReadingBody> {
textAlign: TextAlign.center,
style: theme.textTheme.titleLarge,
inputFormatters: const [KanaInputFormatter()],
// Romaji as typed: no corrections, suggestions or
// curly quotes (n' is ん).
autocorrect: false,
enableSuggestions: false,
smartQuotesType: SmartQuotesType.disabled,
smartDashesType: SmartDashesType.disabled,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
hintText: l10n.studyTypedHint,
border: const OutlineInputBorder(),
),
onSubmitted: (_) => _check(),
// Done with nothing typed keeps the field, and its
// keyboard, rather than letting go.
onEditingComplete: _check,
),
),
)

View file

@ -33,6 +33,9 @@ class _StudyLockState() extends State<StudyLock> {
@override
void initState() {
super.initState();
// A field being typed in (a reading) lets go, and its keyboard with
// it: nothing behind the lock answers a key.
FocusManager.instance.primaryFocus?.unfocus();
if (_pomodoro.state.run?.isRunning ?? false) {
_paused = true;
_pomodoro.add(const PomodoroEventPauseRequested());

View file

@ -101,6 +101,46 @@ void main() {
);
});
test('writes a reading once over text formatted in parts, each part '
'keeping its marks; the reading as HTML text', () {
expect(
markdown([
text('paragraph', [
{
'insert': '漢',
'attributes': {'ruby': 'か<ん>じ', 'bold': true},
},
{
'insert': '字',
'attributes': {'ruby': 'か<ん>じ'},
},
]),
]),
'<ruby>**漢**字<rt>か&lt;ん&gt;じ</rt></ruby>',
);
});
test('escapes what Markdown would read as its own syntax; code as it '
'is, and blank lines kept in code blocks', () {
expect(
markdown([
plain('paragraph', '1990. A *star* was _born_ <here> | there'),
text('paragraph', [
{
'insert': 'a*b',
'attributes': {'code': true},
},
]),
plain('paragraph', 'Fill in: ___'),
]),
[
r'1990\. A \*star\* was \_born\_ \<here\> \| there',
'`a*b`',
r'Fill in: \_\_\_',
].join('\n\n'),
);
});
test("links the person's images and sounds to their files, other images "
'to where they are', () {
expect(

View file

@ -1,7 +1,10 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:material_ui/material_ui.dart';
void main() {
@ -186,4 +189,51 @@ void main() {
expect(reading.center.dx, moreOrLessEquals(text.center.dx, epsilon: 1));
expect(tester.getSize(find.text('かんじ')).height, lessThan(text.height));
});
testWidgets('a reading over text formatted in parts is drawn once, over '
'all of it', (tester) async {
await show(
tester,
RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{
'insert': '漢',
'attributes': {'ruby': 'かんじ', 'bold': true},
},
{
'insert': '字',
'attributes': {'ruby': 'かんじ'},
},
],
},
},
],
},
}),
);
expect(find.text('かんじ'), findsOneWidget);
expect(find.text('漢字'), findsOneWidget);
});
test("text typed right after a reading's text goes without the reading, "
'as after a link', () {
// The editor's styles set this up as they are made.
withRuby(
(context, node, index, text, before, after) => before,
editing: true,
);
final delta = Delta()
..insert('漢字', attributes: {rubyKey: 'かんじ'})
..insert('を');
expect(delta.sliceAttributes(2)?[rubyKey], isNull);
expect(delta.sliceAttributes(1)?[rubyKey], 'かんじ');
});
}

View file

@ -19,6 +19,34 @@ void main() {
expect(romajiToHiragana('たべru'), 'たべる');
});
test('Hepburn as learners type it: a doubled n before a vowel or y is ん '
'and the n-row, m before b, p and m is ん; keyboards spell ん before '
"a vowel as n' or nn typed once more", () {
expect(romajiToHiragana('onna'), 'おんな');
expect(romajiToHiragana('minna'), 'みんな');
expect(romajiToHiragana('konnichiwa'), 'こんにちわ');
expect(romajiToHiragana('annai'), 'あんない');
expect(romajiToHiragana('shinnyuusei'), 'しんにゅうせい');
expect(romajiToHiragana('konnnichiha'), 'こんにちは');
expect(romajiToHiragana("kon'ya"), 'こんや');
expect(romajiToHiragana('kon’ya'), 'こんや');
expect(romajiToHiragana('shimbun', finished: true), 'しんぶん');
expect(romajiToHiragana('tempura'), 'てんぷら');
expect(romajiToHiragana('tsudzuku'), 'つづく');
// A last nn waits for the next letter, unless that was the last.
expect(romajiToHiragana('onn'), 'おnn');
expect(romajiToHiragana('honn', finished: true), 'ほん');
});
test('a typed reading counts in each way it can be read: macrons as two '
'vowels either way; a reading field may give several', () {
expect(typedReadings('tōkyō'), contains('とうきょう'));
expect(typedReadings('ōkii'), contains('おおきい'));
expect(typedReadings('タベル'), {'たべる'});
expect(givenReadings('いく、ゆく'), {'いく', 'ゆく'});
expect(givenReadings(' カード '), {'かーど'});
});
test('katakana count as hiragana', () {
expect(katakanaToHiragana('タベル'), 'たべる');
expect(katakanaToHiragana('ラーメン'), 'らーめん');
@ -34,6 +62,17 @@ void main() {
expect(typed.text, 'たべ');
expect(typed.selection, const TextSelection.collapsed(offset: 2));
// Typed in the middle, the caret stays after what was typed there.
final middle = formatter.formatEditUpdate(
TextEditingValue.empty,
const TextEditingValue(
text: 'たkaべ',
selection: TextSelection.collapsed(offset: 3),
),
);
expect(middle.text, 'たかべ');
expect(middle.selection, const TextSelection.collapsed(offset: 2));
const composing = TextEditingValue(
text: 'tabe',
composing: TextRange(start: 0, end: 4),

View file

@ -184,6 +184,24 @@ void main() {
{'insert': 'を読む'},
]);
});
test('formatting inside a ruby stays, with the reading over it', () {
final content = ankiContent([
'<ruby><b>漢</b>字<rt>かんじ</rt></ruby>',
], assetOf: files.get);
final document = content.editorJson['document']! as Map<String, Object?>;
final line = (document['children']! as List).single as Map;
expect((line['data']! as Map)['delta'], [
{
'insert': '漢',
'attributes': {'ruby': 'かんじ', 'bold': true},
},
{
'insert': '字',
'attributes': {'ruby': 'かんじ'},
},
]);
});
}
extension on Map<String, String> {