feat(editor): text above is named by the language, shown on hover or a long press

Furigana was the only name for text set above text, though it carries
pinyin, a pronunciation or a translation as well. Its button and dialog
now say furigana in a Japanese studyset, pinyin in a Chinese one, and
text above elsewhere, each with an example of its own: the studyset's
languages reach the core's editors through ContentLanguages, which the
studyset's language scope now sets.

Read-only, the text above shows only under the pointer or after a long
press (another hides it), so the text can be read without it first; its
room stays, so no line moves. While editing, it shows over the pointer
above its dotted text; with the caret inside such text, the button
selects all of it and edits what it has, so it can be seen and changed
without finding its ends.

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-12 23:14:04 +02:00
commit a0b4fa969c
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
12 changed files with 487 additions and 79 deletions

View file

@ -790,7 +790,9 @@
"formatUnderline": "Unterstrichen",
"formatSuperscript": "Hochgestellt",
"formatSubscript": "Tiefgestellt",
"formatRuby": "Furigana (eine Lesung über dem Text)",
"formatRuby": "Text darüber (Aussprache, Übersetzung)",
"formatRubyFurigana": "Furigana (eine Lesung über dem Text)",
"formatRubyPinyin": "Pinyin (über den Schriftzeichen)",
"formatLink": "Link",
"linkTitle": "Link",
"linkText": "Text",
@ -798,7 +800,12 @@
"linkAddressHint": "https://…",
"linkSave": "Speichern",
"linkRemove": "Link entfernen",
"rubyTitle": "Furigana",
"rubyTitle": "Text darüber",
"rubyTitleFurigana": "Furigana",
"rubyTitlePinyin": "Pinyin",
"rubyText": "Text",
"rubyHint": "z. B. eine Aussprache oder eine Übersetzung",
"rubyHintPinyin": "Mit Tonzeichen, z. B. hànzì",
"rubyReading": "Lesung",
"rubyReadingHint": "In Kana, z. B. かんじ",
"rubySave": "Speichern",

View file

@ -888,7 +888,9 @@
"formatUnderline": "Underline",
"formatSuperscript": "Superscript",
"formatSubscript": "Subscript",
"formatRuby": "Furigana (a reading above the text)",
"formatRuby": "Text above (a pronunciation, a translation)",
"formatRubyFurigana": "Furigana (a reading above the text)",
"formatRubyPinyin": "Pinyin (above the characters)",
"formatLink": "Link",
"linkTitle": "Link",
"linkText": "Text",
@ -896,9 +898,14 @@
"linkAddressHint": "https://…",
"linkSave": "Save",
"linkRemove": "Remove link",
"rubyTitle": "Furigana",
"rubyTitle": "Text above",
"rubyTitleFurigana": "Furigana",
"rubyTitlePinyin": "Pinyin",
"rubyText": "Text",
"rubyHint": "e.g. a pronunciation or a translation",
"rubyReading": "Reading",
"rubyReadingHint": "In kana, e.g. かんじ",
"rubyHintPinyin": "With tone marks, e.g. hànzì",
"rubySave": "Save",
"rubyRemove": "Remove",
"vocabularyReading": "Reading",

View file

@ -4645,9 +4645,21 @@ abstract class AppLocalizations {
/// No description provided for @formatRuby.
///
/// In en, this message translates to:
/// **'Furigana (a reading above the text)'**
/// **'Text above (a pronunciation, a translation)'**
String get formatRuby;
/// No description provided for @formatRubyFurigana.
///
/// In en, this message translates to:
/// **'Furigana (a reading above the text)'**
String get formatRubyFurigana;
/// No description provided for @formatRubyPinyin.
///
/// In en, this message translates to:
/// **'Pinyin (above the characters)'**
String get formatRubyPinyin;
/// No description provided for @formatLink.
///
/// In en, this message translates to:
@ -4693,9 +4705,33 @@ abstract class AppLocalizations {
/// No description provided for @rubyTitle.
///
/// In en, this message translates to:
/// **'Furigana'**
/// **'Text above'**
String get rubyTitle;
/// No description provided for @rubyTitleFurigana.
///
/// In en, this message translates to:
/// **'Furigana'**
String get rubyTitleFurigana;
/// No description provided for @rubyTitlePinyin.
///
/// In en, this message translates to:
/// **'Pinyin'**
String get rubyTitlePinyin;
/// No description provided for @rubyText.
///
/// In en, this message translates to:
/// **'Text'**
String get rubyText;
/// No description provided for @rubyHint.
///
/// In en, this message translates to:
/// **'e.g. a pronunciation or a translation'**
String get rubyHint;
/// No description provided for @rubyReading.
///
/// In en, this message translates to:
@ -4708,6 +4744,12 @@ abstract class AppLocalizations {
/// **'In kana, e.g. かんじ'**
String get rubyReadingHint;
/// No description provided for @rubyHintPinyin.
///
/// In en, this message translates to:
/// **'With tone marks, e.g. hànzì'**
String get rubyHintPinyin;
/// No description provided for @rubySave.
///
/// In en, this message translates to:

View file

@ -2885,7 +2885,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get formatSubscript => 'Tiefgestellt';
@override
String get formatRuby => 'Furigana (eine Lesung über dem Text)';
String get formatRuby => 'Text darüber (Aussprache, Übersetzung)';
@override
String get formatRubyFurigana => 'Furigana (eine Lesung über dem Text)';
@override
String get formatRubyPinyin => 'Pinyin (über den Schriftzeichen)';
@override
String get formatLink => 'Link';
@ -2909,7 +2915,19 @@ class AppLocalizationsDe extends AppLocalizations {
String get linkRemove => 'Link entfernen';
@override
String get rubyTitle => 'Furigana';
String get rubyTitle => 'Text darüber';
@override
String get rubyTitleFurigana => 'Furigana';
@override
String get rubyTitlePinyin => 'Pinyin';
@override
String get rubyText => 'Text';
@override
String get rubyHint => 'z. B. eine Aussprache oder eine Übersetzung';
@override
String get rubyReading => 'Lesung';
@ -2917,6 +2935,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get rubyReadingHint => 'In Kana, z. B. かんじ';
@override
String get rubyHintPinyin => 'Mit Tonzeichen, z. B. hànzì';
@override
String get rubySave => 'Speichern';

View file

@ -2863,7 +2863,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get formatSubscript => 'Subscript';
@override
String get formatRuby => 'Furigana (a reading above the text)';
String get formatRuby => 'Text above (a pronunciation, a translation)';
@override
String get formatRubyFurigana => 'Furigana (a reading above the text)';
@override
String get formatRubyPinyin => 'Pinyin (above the characters)';
@override
String get formatLink => 'Link';
@ -2887,7 +2893,19 @@ class AppLocalizationsEn extends AppLocalizations {
String get linkRemove => 'Remove link';
@override
String get rubyTitle => 'Furigana';
String get rubyTitle => 'Text above';
@override
String get rubyTitleFurigana => 'Furigana';
@override
String get rubyTitlePinyin => 'Pinyin';
@override
String get rubyText => 'Text';
@override
String get rubyHint => 'e.g. a pronunciation or a translation';
@override
String get rubyReading => 'Reading';
@ -2895,6 +2913,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get rubyReadingHint => 'In kana, e.g. かんじ';
@override
String get rubyHintPinyin => 'With tone marks, e.g. hànzì';
@override
String get rubySave => 'Save';

View file

@ -11,7 +11,8 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
/// Formats the selection of [editorState]: alignment, bold, italic,
/// underline, a link, superscript and subscript, furigana, and with [onFormula] formulas
/// underline, a link, superscript and subscript, text above (furigana),
/// and with [onFormula] formulas
/// within the line. Each button shows whether the selection has its format
/// already. With [onInsert], an Insert button comes first, for blocks; the
/// [pinned] blocks have buttons at the end, outside the scrolling, so they
@ -204,7 +205,7 @@ class _FormatBarState() extends State<FormatBar> {
),
_FormatButton(
icon: LucideIcons.languages,
tooltip: l10n.formatRuby,
tooltip: rubyButtonName(context),
active:
editorState.selection != null &&
editorState.getDeltaAttributeValueInSelection<String>(

View file

@ -14,6 +14,7 @@ import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart';
import 'package:halkyon_learn_flutter/src/core/editor/link.dart';
import 'package:halkyon_learn_flutter/src/core/editor/math_block.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_document.dart';
import 'package:halkyon_learn_flutter/src/core/editor/ruby.dart';
import 'package:material_ui/material_ui.dart';
/// Edits [initialContent] and reports each change as new [RichContent].
@ -85,6 +86,8 @@ class _RichContentEditorState() extends State<RichContentEditor> {
@override
void dispose() {
// Its text with text above may be under the pointer.
hideHoverReading();
_focus.removeListener(_onFocus);
_ownFocus?.dispose();
unawaited(_changes.cancel());

View file

@ -2,30 +2,80 @@ 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:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_learn_flutter/src/core/widget/text_limits.dart';
import 'package:material_ui/material_ui.dart';
// Furigana (ruby): the reading of text within a line, kept on the text's
// attributes in the document as `rubyKey` (content_blocks.dart). Lessons,
// example sentences and notes carry readings this way, word by word.
// Text above (ruby): text set small above text within a line, kept on the
// text's attributes in the document as `rubyKey` (content_blocks.dart): a
// reading (furigana over Japanese, pinyin over Chinese), a pronunciation, a
// translation. Lessons, example sentences and notes carry readings this
// way, word by word. Read-only, it shows under the pointer or after a long
// press, so the text can be read without it first; its room is kept.
/// How large a reading is beside the text it reads.
const _rubyScale = 0.5;
/// Gives the text selected in [editorState] a reading, asked for starting
/// from the one it has; an empty one takes it away. Nothing without text
/// selected: a reading belongs to the text it reads.
/// What text above is called where content is written in a language that
/// has a name for it: furigana over Japanese, pinyin over Chinese, the
/// front's language first.
enum _RubyKind() {
furigana,
pinyin,
any;
static _RubyKind of(BuildContext context) {
final (:front, :back) = ContentLanguages.of(context);
for (final tag in [front, back]) {
switch (tag?.split('-').first.toLowerCase()) {
case 'ja':
return furigana;
case 'zh':
return pinyin;
}
}
return any;
}
}
/// The format bar's name for text above, in the terms of the language of
/// the content around [context].
String rubyButtonName(BuildContext context) {
final l10n = AppLocalizations.of(context);
return switch (_RubyKind.of(context)) {
_RubyKind.furigana => l10n.formatRubyFurigana,
_RubyKind.pinyin => l10n.formatRubyPinyin,
_RubyKind.any => l10n.formatRuby,
};
}
/// Gives the text selected in [editorState] text above, asked for starting
/// from what it has; empty takes it away. With the caret in text that has
/// text above (or right after it), that whole text is selected and edited,
/// so what is there can be seen and changed without finding its ends.
/// Nothing at a caret elsewhere: text above belongs to the text it is over.
Future<void> editRuby(BuildContext context, EditorState editorState) async {
final selection = editorState.selection;
if (selection == null || selection.isCollapsed) {
var selection = editorState.selection;
if (selection == null) {
return;
}
if (selection.isCollapsed) {
final run = _runAround(editorState, selection.start);
if (run == null) {
return;
}
selection = editorState.selection = run;
}
hideHoverReading();
final current = editorState.getDeltaAttributeValueInSelection<String>(
rubyKey,
selection,
);
final kind = _RubyKind.of(context);
final reading = await showDialog<String>(
context: context,
builder: (context) => _ReadingDialog(initial: current ?? ''),
builder: (dialogContext) =>
_ReadingDialog(initial: current ?? '', kind: kind),
);
editorState.selection = selection;
if (reading == null) {
@ -69,6 +119,9 @@ TextSpanDecoratorForAttribute withRuby(
mouseCursor: span.mouseCursor,
semanticsLabel: span.semanticsLabel,
locale: span.locale,
// What it says shows over the pointer.
onEnter: (event) => _showHoverReading(context, reading, event.position),
onExit: (_) => hideHoverReading(),
);
}
final run = _runAt(node.delta, index, reading);
@ -92,6 +145,7 @@ TextSpanDecoratorForAttribute withRuby(
),
reading: reading,
style: style,
hidden: true,
);
return WidgetSpan(
alignment: PlaceholderAlignment.baseline,
@ -107,6 +161,105 @@ TextSpanDecoratorForAttribute withRuby(
};
}
/// The text above under the pointer while editing, shown over it.
OverlayEntry? _hoverReading;
void _showHoverReading(BuildContext context, String reading, Offset at) {
hideHoverReading();
final overlay = Overlay.maybeOf(context, rootOverlay: true);
final box = overlay?.context.findRenderObject();
if (overlay == null || box is! RenderBox) {
return;
}
final position = box.globalToLocal(at);
final theme = Theme.of(context);
overlay.insert(
_hoverReading = OverlayEntry(
builder: (overlayContext) => Positioned(
left: position.dx,
top: position.dy - 12,
child: FractionalTranslation(
translation: const Offset(-0.5, -1),
child: IgnorePointer(
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.inverseSurface,
borderRadius: BorderRadius.circular(4),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
reading,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onInverseSurface,
),
),
),
),
),
),
),
),
);
}
/// Takes away the text above shown over the pointer: when the pointer
/// leaves its text, and when the editor goes, which the text doesn't learn.
void hideHoverReading() {
_hoverReading?.remove();
_hoverReading = null;
}
/// The text with text above around the caret [at] in [editorState]: the
/// pieces with the same text above as the one the caret is in, else the one
/// it is right after, else right before; null where there is none.
Selection? _runAround(EditorState editorState, Position at) {
final delta = editorState.getNodeAtPath(at.path)?.delta;
if (delta == null) {
return null;
}
final pieces = <({int start, int end, String? reading})>[];
var offset = 0;
for (final op in delta) {
pieces.add((
start: offset,
end: offset + op.length,
reading: switch (op.attributes?[rubyKey]) {
final String reading when reading.isNotEmpty => reading,
_ => null,
},
));
offset += op.length;
}
int? find(bool Function(({int start, int end, String? reading})) test) {
final index = pieces.indexWhere(
(piece) => piece.reading != null && test(piece),
);
return index < 0 ? null : index;
}
final hit =
find((piece) => piece.start < at.offset && at.offset < piece.end) ??
find((piece) => piece.end == at.offset) ??
find((piece) => piece.start == at.offset);
if (hit == null) {
return null;
}
final reading = pieces[hit].reading;
var first = hit;
while (first > 0 && pieces[first - 1].reading == reading) {
first--;
}
var last = hit;
while (last < pieces.length - 1 && pieces[last + 1].reading == reading) {
last++;
}
return Selection(
start: Position(path: at.path, offset: pieces[first].start),
end: Position(path: at.path, offset: pieces[last].end),
);
}
/// 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.
@ -169,42 +322,76 @@ TextStyle _styled(TextStyle style, Attributes? attributes) => style.copyWith(
/// [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.
/// [hidden], the reading shows only under the pointer or once a long press
/// shows it (another hides it), its room kept so the lines don't move.
class const RubyText({
required final InlineSpan base,
required final String reading,
required final TextStyle style,
final bool hidden = false,
super.key,
}) extends StatelessWidget {
}) extends StatefulWidget {
@override
Widget build(BuildContext context) => Semantics(
label: '${base.toPlainText()} ($reading)',
excludeSemantics: true,
State<RubyText> createState() => _RubyTextState();
}
class _RubyTextState() extends State<RubyText> {
var _hovered = false;
var _pressed = false;
@override
Widget build(BuildContext context) {
final style = widget.style;
final reading = Text(
widget.reading,
textScaler: TextScaler.noScaling,
style: style.copyWith(
fontSize: (style.fontSize ?? 16) * _rubyScale,
height: 1.1,
fontWeight: FontWeight.normal,
decoration: TextDecoration.none,
),
);
// Laid out from the bottom: the text comes first, so the box takes its
// baseline, and the line's text stays level with it.
child: Column(
final ruby = Column(
mainAxisSize: MainAxisSize.min,
verticalDirection: VerticalDirection.up,
children: [
Text.rich(base, style: style, textScaler: TextScaler.noScaling),
Text(
Text.rich(widget.base, style: style, textScaler: TextScaler.noScaling),
if (widget.hidden)
AnimatedOpacity(
opacity: _hovered || _pressed ? 1 : 0,
duration: const Duration(milliseconds: 120),
child: reading,
)
else
reading,
textScaler: TextScaler.noScaling,
style: style.copyWith(
fontSize: (style.fontSize ?? 16) * _rubyScale,
height: 1.1,
fontWeight: FontWeight.normal,
decoration: TextDecoration.none,
),
),
],
),
);
);
return Semantics(
label: '${widget.base.toPlainText()} (${widget.reading})',
excludeSemantics: true,
child: !widget.hidden
? ruby
: MouseRegion(
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onLongPress: () => setState(() => _pressed = !_pressed),
child: ruby,
),
),
);
}
}
/// Asks for a reading, starting from [initial]: completes with it, empty to
/// take the reading away, or with null when cancelled.
class const _ReadingDialog({required final String initial})
extends StatefulWidget {
/// Asks for text above, starting from [initial], named as [kind] calls it:
/// completes with it, empty to take it away, or with null when cancelled.
class const _ReadingDialog({
required final String initial,
required final _RubyKind kind,
}) extends StatefulWidget {
@override
State<_ReadingDialog> createState() => _ReadingDialogState();
}
@ -221,16 +408,29 @@ class _ReadingDialogState() extends State<_ReadingDialog> {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final (title, label, hint) = switch (widget.kind) {
_RubyKind.furigana => (
l10n.rubyTitleFurigana,
l10n.rubyReading,
l10n.rubyReadingHint,
),
_RubyKind.pinyin => (
l10n.rubyTitlePinyin,
l10n.rubyTitlePinyin,
l10n.rubyHintPinyin,
),
_RubyKind.any => (l10n.rubyTitle, l10n.rubyText, l10n.rubyHint),
};
return AlertDialog(
title: Text(l10n.rubyTitle),
title: Text(title),
content: TextField(
maxLength: TextLimits.secondary,
buildCounter: nearLimitCounter,
controller: _reading,
autofocus: true,
decoration: InputDecoration(
labelText: l10n.rubyReading,
hintText: l10n.rubyReadingHint,
labelText: label,
hintText: hint,
border: const OutlineInputBorder(),
),
onSubmitted: (reading) => Navigator.of(context).pop(reading),

View file

@ -0,0 +1,22 @@
import 'package:material_ui/material_ui.dart';
/// The languages of the content below, as BCP 47 tags: a studyset's front
/// and back, which every screen of a studyset sets. The core's editors read
/// them to name what they offer in the language's own terms (text above
/// Japanese is furigana).
class const ContentLanguages({
required final ({String? front, String? back}) languages,
required super.child,
super.key,
}) extends InheritedWidget {
/// The languages around [context]; none outside a studyset.
static ({String? front, String? back}) of(BuildContext context) =>
context
.dependOnInheritedWidgetOfExactType<ContentLanguages>()
?.languages ??
(front: null, back: null);
@override
bool updateShouldNotify(ContentLanguages oldWidget) =>
languages != oldWidget.languages;
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:halkyon_learn_flutter/src/core/theme/app_fonts.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:material_ui/material_ui.dart';
@ -20,8 +21,7 @@ class const StudysetLanguageScope({
}) extends StatelessWidget {
/// The languages of the studyset around [context]; none outside one.
static StudysetLanguages of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_Languages>()?.languages ??
(front: null, back: null);
ContentLanguages.of(context);
@override
Widget build(BuildContext context) =>
@ -57,16 +57,7 @@ class const LanguageScope({
fontFamilyFallback: fallback,
),
),
child: _Languages(languages: languages, child: child),
child: ContentLanguages(languages: languages, child: child),
);
}
}
class const _Languages({
required final StudysetLanguages languages,
required super.child,
}) extends InheritedWidget {
@override
bool updateShouldNotify(_Languages oldWidget) =>
languages != oldWidget.languages;
}

View file

@ -7,28 +7,38 @@ import 'package:halkyon_learn_flutter/src/core/editor/format_bar.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_editor.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/core/editor/scripts.dart';
import 'package:halkyon_learn_flutter/src/core/widget/content_languages.dart';
import 'package:material_ui/material_ui.dart';
void main() {
late RichContent latest;
Future<EditorState> edit(WidgetTester tester, String text) async {
latest = RichContent.plain(text);
/// Edits [text], or [content], written in [language] where given.
Future<EditorState> edit(
WidgetTester tester,
String text, {
RichContent? content,
String? language,
}) async {
latest = content ?? RichContent.plain(text);
await tester.pumpWidget(
MaterialApp(
localizationsDelegates: AppLocalizations.localizationsDelegates,
home: Scaffold(
body: ListView(
children: [
SizedBox(
height: 400,
child: RichContentEditor(
initialContent: latest,
onChanged: (content) => latest = content,
formatBar: true,
home: ContentLanguages(
languages: (front: language, back: 'en'),
child: Scaffold(
body: ListView(
children: [
SizedBox(
height: 400,
child: RichContentEditor(
initialContent: latest,
onChanged: (content) => latest = content,
formatBar: true,
),
),
),
],
],
),
),
),
),
@ -121,16 +131,16 @@ void main() {
expect(runs()[1], {subscriptKey: true});
});
testWidgets('furigana gives the selected text a reading, asked for, and '
'takes it away again', (tester) async {
final editorState = await edit(tester, '漢字を読む');
testWidgets('text above is furigana in Japanese: it gives the selected '
'text a reading, asked for, and takes it away again', (tester) async {
final editorState = await edit(tester, '漢字を読む', language: 'ja');
await select(tester, editorState, 0, 2);
await tester.ensureVisible(
find.byTooltip('Furigana (a reading above the text)'),
);
await press(tester, 'Furigana (a reading above the text)');
const furigana = 'Furigana (a reading above the text)';
await tester.ensureVisible(find.byTooltip(furigana));
await press(tester, furigana);
await tester.pumpAndSettle();
expect(find.text('In kana, e.g. かんじ'), findsOneWidget);
await tester.enterText(find.byType(TextField).last, 'かんじ');
await tester.tap(find.text('Save'));
await tester.pump(const Duration(seconds: 1));
@ -140,7 +150,7 @@ void main() {
]);
await select(tester, editorState, 0, 2);
await press(tester, 'Furigana (a reading above the text)');
await press(tester, furigana);
await tester.pumpAndSettle();
expect(find.widgetWithText(TextField, 'かんじ'), findsOneWidget);
await tester.tap(find.text('Remove'));
@ -148,6 +158,59 @@ void main() {
expect(runs(), everyElement(isNull));
});
testWidgets('elsewhere it is text above; with the caret inside such text '
'the button edits all of it', (tester) async {
final editorState = await edit(
tester,
'',
content: RichContent({
'document': {
'type': 'page',
'children': [
{
'type': 'paragraph',
'data': {
'delta': [
{'insert': 'I '},
{
'insert': 'like',
'attributes': {rubyKey: 'mag'},
},
{
'insert': ' it',
'attributes': {rubyKey: 'mag', 'bold': true},
},
],
},
},
],
},
}),
);
await select(tester, editorState, 4, 4);
const textAbove = 'Text above (a pronunciation, a translation)';
await tester.ensureVisible(find.byTooltip(textAbove));
await press(tester, textAbove);
await tester.pumpAndSettle();
expect(find.text('e.g. a pronunciation or a translation'), findsOneWidget);
expect(find.widgetWithText(TextField, 'mag'), findsOneWidget);
await tester.enterText(find.byType(TextField).last, 'mag es');
await tester.tap(find.text('Save'));
await tester.pump(const Duration(seconds: 1));
expect(runs(), [
null,
{rubyKey: 'mag es'},
{rubyKey: 'mag es', 'bold': true},
]);
// A caret in text without any does nothing.
await select(tester, editorState, 1, 1);
await press(tester, textAbove);
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('centres the selected line', (tester) async {
final editorState = await edit(tester, 'Water is H2O');
await select(tester, editorState, 0, 5);

View file

@ -1,4 +1,5 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/gestures.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';
@ -283,6 +284,35 @@ void main() {
expect(reading.bottom, lessThanOrEqualTo(text.top + 1));
expect(reading.center.dx, moreOrLessEquals(text.center.dx, epsilon: 1));
expect(tester.getSize(find.text('かんじ')).height, lessThan(text.height));
// Unseen until the pointer is over it, or a long press shows it; its
// room is kept, so the line doesn't move.
double shown() => tester
.widget<AnimatedOpacity>(
find.ancestor(
of: find.text('かんじ'),
matching: find.byType(AnimatedOpacity),
),
)
.opacity;
expect(shown(), 0);
final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse);
await mouse.addPointer(location: Offset.zero);
addTearDown(mouse.removePointer);
await mouse.moveTo(tester.getCenter(find.text('漢字')));
await tester.pumpAndSettle();
expect(shown(), 1);
expect(tester.getRect(find.text('漢字')), text);
await mouse.moveTo(Offset.zero);
await tester.pumpAndSettle();
expect(shown(), 0);
await tester.longPress(find.text('漢字'));
await tester.pumpAndSettle();
expect(shown(), 1);
await tester.longPress(find.text('漢字'));
await tester.pumpAndSettle();
expect(shown(), 0);
});
testWidgets('a reading over text formatted in parts is drawn once, over '