feat(jellyfin): any stretch of a line's characters chosen by hand
The cut takes the longest word and knows no grammar, so 今日は reads as こんにちは where it is 今日 and は. Any stretch of a line's characters can now be chosen by hand: dragged across with a mouse, Shift+clicked from the word or stretch chosen, or on a touch screen held on a character and dragged on. It is looked up as one word, as it stands or as a form of one, in one small request (the instance has the line's text already); while it is chosen the line reads it as that word (きょう over 今日), and what the dictionary lacks as one word is offered as a search on Jisho. The line's words move into line_words.dart, out of the Watch screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
9b5ea88a8a
commit
48ac84d92c
12 changed files with 918 additions and 209 deletions
|
|
@ -1840,7 +1840,10 @@
|
|||
"watchWordCount": "{count}×",
|
||||
"watchAdd": "Hinzufügen",
|
||||
"watchAdded": "{word} hinzugefügt",
|
||||
"watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen.",
|
||||
"watchStretchNone": "„{text}“ steht nicht als ein Wort im Wörterbuch.",
|
||||
"watchStretchFailed": "Das Nachschlagen hat nicht geklappt. Wähle es noch einmal, um es erneut zu versuchen.",
|
||||
"watchStretchJisho": "Auf Jisho suchen",
|
||||
"watchTapAWord": "Tipp auf ein Wort der Untertitel, um es nachzuschlagen, oder zieh über Zeichen, um einen beliebigen Teil einer Zeile nachzuschlagen (auf einem Touchscreen erst gedrückt halten).",
|
||||
"watchFormOf": "Eine Form von {word}",
|
||||
"watchFailed": "Das Video konnte nicht abgespielt werden."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2158,8 +2158,12 @@
|
|||
"@watchWordCount": {"placeholders": {"count": {"type": "int"}}},
|
||||
"watchAdd": "Add",
|
||||
"watchAdded": "{word} added",
|
||||
"watchStretchNone": "{text} isn't in the dictionary as one word.",
|
||||
"@watchStretchNone": {"placeholders": {"text": {"type": "String"}}},
|
||||
"watchStretchFailed": "Couldn't look it up. Choose it again to try once more.",
|
||||
"watchStretchJisho": "Search Jisho for it",
|
||||
"@watchAdded": {"placeholders": {"word": {"type": "String"}}},
|
||||
"watchTapAWord": "Tap a word of the subtitles to look it up.",
|
||||
"watchTapAWord": "Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).",
|
||||
"watchFormOf": "A form of {word}",
|
||||
"@watchFormOf": {"placeholders": {"word": {"type": "String"}}},
|
||||
"watchFailed": "The video couldn't be played."
|
||||
|
|
|
|||
|
|
@ -10881,10 +10881,28 @@ abstract class AppLocalizations {
|
|||
/// **'{word} added'**
|
||||
String watchAdded(String word);
|
||||
|
||||
/// No description provided for @watchStretchNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{text} isn\'t in the dictionary as one word.'**
|
||||
String watchStretchNone(String text);
|
||||
|
||||
/// No description provided for @watchStretchFailed.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Couldn\'t look it up. Choose it again to try once more.'**
|
||||
String get watchStretchFailed;
|
||||
|
||||
/// No description provided for @watchStretchJisho.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search Jisho for it'**
|
||||
String get watchStretchJisho;
|
||||
|
||||
/// No description provided for @watchTapAWord.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tap a word of the subtitles to look it up.'**
|
||||
/// **'Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).'**
|
||||
String get watchTapAWord;
|
||||
|
||||
/// No description provided for @watchFormOf.
|
||||
|
|
|
|||
|
|
@ -6846,9 +6846,21 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
return '$word hinzugefügt';
|
||||
}
|
||||
|
||||
@override
|
||||
String watchStretchNone(String text) {
|
||||
return '„$text“ steht nicht als ein Wort im Wörterbuch.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get watchStretchFailed =>
|
||||
'Das Nachschlagen hat nicht geklappt. Wähle es noch einmal, um es erneut zu versuchen.';
|
||||
|
||||
@override
|
||||
String get watchStretchJisho => 'Auf Jisho suchen';
|
||||
|
||||
@override
|
||||
String get watchTapAWord =>
|
||||
'Tipp auf ein Wort der Untertitel, um es nachzuschlagen.';
|
||||
'Tipp auf ein Wort der Untertitel, um es nachzuschlagen, oder zieh über Zeichen, um einen beliebigen Teil einer Zeile nachzuschlagen (auf einem Touchscreen erst gedrückt halten).';
|
||||
|
||||
@override
|
||||
String watchFormOf(String word) {
|
||||
|
|
|
|||
|
|
@ -6786,7 +6786,20 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
}
|
||||
|
||||
@override
|
||||
String get watchTapAWord => 'Tap a word of the subtitles to look it up.';
|
||||
String watchStretchNone(String text) {
|
||||
return '$text isn\'t in the dictionary as one word.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get watchStretchFailed =>
|
||||
'Couldn\'t look it up. Choose it again to try once more.';
|
||||
|
||||
@override
|
||||
String get watchStretchJisho => 'Search Jisho for it';
|
||||
|
||||
@override
|
||||
String get watchTapAWord =>
|
||||
'Tap a word of the subtitles to look it up, or drag across characters to look up any part of a line (on a touch screen, hold first).';
|
||||
|
||||
@override
|
||||
String watchFormOf(String word) {
|
||||
|
|
|
|||
|
|
@ -143,6 +143,38 @@ class DictionaryRepository(final DictionaryGateway _gateway) {
|
|||
];
|
||||
}
|
||||
|
||||
/// Stretches chosen by hand looked up already, by dictionary and text;
|
||||
/// the oldest go once there are [_sentencesKept].
|
||||
final _stretches = <(String, String, String), SentencePart>{};
|
||||
|
||||
/// [text], a stretch of a line chosen by hand from [start], looked up in
|
||||
/// [dictionary] as one word (`stretchPart`): one request for its
|
||||
/// [stretchKeys], none for one asked before.
|
||||
Future<SentencePart> wordOfStretch(
|
||||
Dictionary dictionary,
|
||||
String text, {
|
||||
int start = 0,
|
||||
}) async {
|
||||
final key = (dictionary.source, dictionary.glossLanguage, text);
|
||||
if (_stretches[key] case final known?) {
|
||||
return SentencePart(
|
||||
text: known.text,
|
||||
start: start,
|
||||
entries: known.entries,
|
||||
steps: known.steps,
|
||||
);
|
||||
}
|
||||
final keys = stretchKeys(text);
|
||||
final found = keys.isEmpty
|
||||
? const <String, List<DictionaryItem>>{}
|
||||
: await _gateway.withKeys(dictionary, keys.toList());
|
||||
final part = _stretches[key] = stretchPart(text, start, found);
|
||||
while (_stretches.length > _sentencesKept) {
|
||||
_stretches.remove(_stretches.keys.first);
|
||||
}
|
||||
return part;
|
||||
}
|
||||
|
||||
/// How many entries of [dictionary] carry [label] (`jlpt:n5`).
|
||||
Future<int> countLabelled(Dictionary dictionary, String label) =>
|
||||
_gateway.countLabelled(dictionary, label);
|
||||
|
|
|
|||
|
|
@ -201,51 +201,89 @@ List<_Word> _wordsAt(
|
|||
_inWord(characters[start + longest])) {
|
||||
longest++;
|
||||
}
|
||||
final words = <_Word>[];
|
||||
for (var length = longest; length > 0; length--) {
|
||||
final text = String.fromCharCodes(
|
||||
characters.sublist(start, start + length),
|
||||
);
|
||||
final kana = !text.runes.any(_isKanji);
|
||||
final candidates =
|
||||
<({DictionaryWord entry, List<String> steps, int order})>[];
|
||||
final seen = <DictionaryRef>{};
|
||||
for (final form in deinflect(text)) {
|
||||
final key = form.text.toLowerCase();
|
||||
for (final (index, item) in (found[key] ?? const []).indexed) {
|
||||
if (item is! DictionaryWord ||
|
||||
!form.fits([for (final sense in item.senses) ...sense.pos]) ||
|
||||
kana && !_writtenAs(item, key, conjugated: form.steps.isNotEmpty) ||
|
||||
!seen.add(item.ref)) {
|
||||
continue;
|
||||
}
|
||||
candidates.add((
|
||||
entry: item,
|
||||
steps: form.steps,
|
||||
// As it stands before a form of it, the fewest steps first; a
|
||||
// word written as the text is before one only read so, kana
|
||||
// before kanji for kana; then the dictionary's order.
|
||||
order:
|
||||
form.steps.length * 10000 +
|
||||
(item.headword == key ? 0 : 1000) +
|
||||
(kana && !_usuallyKana(item) ? 500 : 0) +
|
||||
index,
|
||||
));
|
||||
return [
|
||||
for (var length = longest; length > 0; length--)
|
||||
?_wordOf(characters, start, length, found),
|
||||
];
|
||||
}
|
||||
|
||||
/// The word [length] characters long at [start], as [found] has it by its
|
||||
/// keys, as it stands or as a form of a word whose class fits; null when
|
||||
/// the dictionary has none.
|
||||
_Word? _wordOf(
|
||||
List<int> characters,
|
||||
int start,
|
||||
int length,
|
||||
Map<String, List<DictionaryItem>> found,
|
||||
) {
|
||||
final text = String.fromCharCodes(characters.sublist(start, start + length));
|
||||
final kana = !text.runes.any(_isKanji);
|
||||
final candidates =
|
||||
<({DictionaryWord entry, List<String> steps, int order})>[];
|
||||
final seen = <DictionaryRef>{};
|
||||
for (final form in deinflect(text)) {
|
||||
final key = form.text.toLowerCase();
|
||||
for (final (index, item) in (found[key] ?? const []).indexed) {
|
||||
if (item is! DictionaryWord ||
|
||||
!form.fits([for (final sense in item.senses) ...sense.pos]) ||
|
||||
kana && !_writtenAs(item, key, conjugated: form.steps.isNotEmpty) ||
|
||||
!seen.add(item.ref)) {
|
||||
continue;
|
||||
}
|
||||
candidates.add((
|
||||
entry: item,
|
||||
steps: form.steps,
|
||||
// As it stands before a form of it, the fewest steps first; a
|
||||
// word written as the text is before one only read so, kana
|
||||
// before kanji for kana; then the dictionary's order.
|
||||
order:
|
||||
form.steps.length * 10000 +
|
||||
(item.headword == key ? 0 : 1000) +
|
||||
(kana && !_usuallyKana(item) ? 500 : 0) +
|
||||
index,
|
||||
));
|
||||
}
|
||||
candidates.sort((a, b) => a.order.compareTo(b.order));
|
||||
final entries = [for (final each in candidates) each.entry];
|
||||
if (entries.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
words.add((
|
||||
length: length,
|
||||
entries: entries,
|
||||
steps: candidates.first.steps,
|
||||
kana: kana,
|
||||
));
|
||||
}
|
||||
return words;
|
||||
if (candidates.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
candidates.sort((a, b) => a.order.compareTo(b.order));
|
||||
return (
|
||||
length: length,
|
||||
entries: [for (final each in candidates) each.entry],
|
||||
steps: candidates.first.steps,
|
||||
kana: kana,
|
||||
);
|
||||
}
|
||||
|
||||
/// The keys a dictionary is asked for to look up [text], a stretch of a
|
||||
/// line chosen by hand, as one word: it as it stands and every word it may
|
||||
/// be a form of.
|
||||
Set<String> stretchKeys(String text) => {
|
||||
if (text.runes.every(_inWord))
|
||||
for (final form in deinflect(text)) form.text,
|
||||
};
|
||||
|
||||
/// [text], a stretch of a line chosen by hand from [start] (decided
|
||||
/// 2026-10-13: by characters, so also part of a word the cut took longer,
|
||||
/// 今日 out of 今日は), as one word, as [found] has it by its
|
||||
/// [stretchKeys]: the entries it may be, as it stands or as a form of one;
|
||||
/// none when it is no word the dictionary has, or holds what no word does.
|
||||
SentencePart stretchPart(
|
||||
String text,
|
||||
int start,
|
||||
Map<String, List<DictionaryItem>> found,
|
||||
) {
|
||||
final characters = text.runes.toList();
|
||||
final word = characters.isNotEmpty && characters.every(_inWord)
|
||||
? _wordOf(characters, 0, characters.length, found)
|
||||
: null;
|
||||
return SentencePart(
|
||||
text: text,
|
||||
start: start,
|
||||
entries: word?.entries ?? const [],
|
||||
steps: word?.steps ?? const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether [entry], found by [key] for text written in kana alone, is a
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
|
@ -33,6 +34,11 @@ enum WatchStatus() {
|
|||
/// A word of the subtitles chosen: the `part` of the `line`.
|
||||
typedef WatchSelection = ({int line, int part});
|
||||
|
||||
/// A stretch of the `line` chosen by hand (decided 2026-10-13: by
|
||||
/// characters): its `text`, from `start` to before `end`, as offsets into
|
||||
/// the line as it shows, joined into one.
|
||||
typedef WatchStretch = ({int line, int start, int end, String text});
|
||||
|
||||
/// A word met in a video: the entry it is, the form it was first met in,
|
||||
/// how often it comes, and the line it first comes in.
|
||||
typedef VideoWord = ({
|
||||
|
|
@ -82,6 +88,16 @@ final class const WatchState({
|
|||
final Map<int, List<SentencePart>> parts = const {},
|
||||
final WatchSelection? selected,
|
||||
|
||||
/// The stretch chosen by hand, in place of a word ([selected]).
|
||||
final WatchStretch? stretch,
|
||||
|
||||
/// The [stretch] looked up as one word: no entries when the dictionary
|
||||
/// has none; null while it is looked up.
|
||||
final SentencePart? stretchPart,
|
||||
|
||||
/// Whether looking the [stretch] up failed.
|
||||
final bool stretchFailed = false,
|
||||
|
||||
/// The dictionary entries cards of the studyset were made from.
|
||||
final Set<DictionaryRef> known = const {},
|
||||
|
||||
|
|
@ -110,11 +126,16 @@ final class const WatchState({
|
|||
String? translationOf(int index) =>
|
||||
index < lines.length ? textDuring(translations, lines[index]) : null;
|
||||
|
||||
/// The part chosen.
|
||||
SentencePart? get selectedPart => switch (selected) {
|
||||
(:final line, :final part) => parts[line]?.elementAtOrNull(part),
|
||||
null => null,
|
||||
};
|
||||
/// The part chosen: the word, or the stretch once looked up.
|
||||
SentencePart? get selectedPart => stretch != null
|
||||
? stretchPart
|
||||
: switch (selected) {
|
||||
(:final line, :final part) => parts[line]?.elementAtOrNull(part),
|
||||
null => null,
|
||||
};
|
||||
|
||||
/// The line of the word or the stretch chosen.
|
||||
int? get chosenLine => stretch?.line ?? selected?.line;
|
||||
|
||||
/// The video's words the list shows, as [filter] admits them.
|
||||
List<VideoWord> get listed => [
|
||||
|
|
@ -144,6 +165,9 @@ final class const WatchState({
|
|||
Object? line = _same,
|
||||
Map<int, List<SentencePart>>? parts,
|
||||
Object? selected = _same,
|
||||
Object? stretch = _same,
|
||||
Object? stretchPart = _same,
|
||||
bool? stretchFailed,
|
||||
Set<DictionaryRef>? known,
|
||||
Object? words = _same,
|
||||
bool? pauseAtLineEnd,
|
||||
|
|
@ -173,6 +197,11 @@ final class const WatchState({
|
|||
line: line == _same ? this.line : line as int?,
|
||||
parts: parts ?? this.parts,
|
||||
selected: selected == _same ? this.selected : selected as WatchSelection?,
|
||||
stretch: stretch == _same ? this.stretch : stretch as WatchStretch?,
|
||||
stretchPart: stretchPart == _same
|
||||
? this.stretchPart
|
||||
: stretchPart as SentencePart?,
|
||||
stretchFailed: stretchFailed ?? this.stretchFailed,
|
||||
known: known ?? this.known,
|
||||
words: words == _same ? this.words : words as List<VideoWord>?,
|
||||
pauseAtLineEnd: pauseAtLineEnd ?? this.pauseAtLineEnd,
|
||||
|
|
@ -199,6 +228,9 @@ final class const WatchState({
|
|||
line,
|
||||
parts,
|
||||
selected,
|
||||
stretch,
|
||||
stretchPart,
|
||||
stretchFailed,
|
||||
known,
|
||||
words,
|
||||
pauseAtLineEnd,
|
||||
|
|
@ -431,10 +463,40 @@ class WatchCubit(
|
|||
/// Chooses the [part] of the [line]: its entries show, and the video
|
||||
/// pauses, as reading a word takes a moment.
|
||||
void select(int line, int part) {
|
||||
emit(state.copyWith(selected: (line: line, part: part)));
|
||||
emit(state.copyWith(selected: (line: line, part: part), stretch: null));
|
||||
unawaited(_playback?.pause());
|
||||
}
|
||||
|
||||
/// Chooses the characters of the [line] from [start] to before [end],
|
||||
/// [text], by hand, and looks them up as one word; the video pauses, as
|
||||
/// for a word.
|
||||
Future<void> selectStretch(int line, int start, int end, String text) async {
|
||||
final stretch = (line: line, start: start, end: end, text: text);
|
||||
emit(
|
||||
state.copyWith(
|
||||
selected: null,
|
||||
stretch: stretch,
|
||||
stretchPart: null,
|
||||
stretchFailed: false,
|
||||
),
|
||||
);
|
||||
unawaited(_playback?.pause());
|
||||
final dictionary = this.dictionary;
|
||||
try {
|
||||
final part = dictionary == null
|
||||
? SentencePart(text: text, start: start)
|
||||
: await _dictionaries.wordOfStretch(dictionary, text, start: start);
|
||||
if (!isClosed && state.stretch == stretch) {
|
||||
emit(state.copyWith(stretchPart: part));
|
||||
}
|
||||
} on Exception catch (error) {
|
||||
log('Looking up a stretch failed', name: 'Watch', error: error);
|
||||
if (!isClosed && state.stretch == stretch) {
|
||||
emit(state.copyWith(stretchFailed: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Goes to the first line [word] comes in, paused, the word chosen.
|
||||
Future<void> showWord(VideoWord word) async {
|
||||
final parts = state.parts[word.firstLine] ?? const <SentencePart>[];
|
||||
|
|
@ -449,13 +511,14 @@ class WatchCubit(
|
|||
state.copyWith(
|
||||
line: word.firstLine,
|
||||
selected: (line: word.firstLine, part: part),
|
||||
stretch: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Chooses no word: the video's words show again.
|
||||
void unselect() => emit(state.copyWith(selected: null));
|
||||
void unselect() => emit(state.copyWith(selected: null, stretch: null));
|
||||
|
||||
/// Adds [entry] to the studyset as a card, tagged with the show and the
|
||||
/// episode. Its hints are the dictionary's examples alone, never the line
|
||||
|
|
@ -463,7 +526,7 @@ class WatchCubit(
|
|||
/// a line may be wrong or mistyped, and every episode's line would pile
|
||||
/// up on a common word.
|
||||
Future<void> add(DictionaryWord entry) async {
|
||||
if (state.selected == null) {
|
||||
if (state.selectedPart == null) {
|
||||
return;
|
||||
}
|
||||
await _adder.add(studysetId, [entry], jellyfinTagNames: _tagNames);
|
||||
|
|
@ -626,6 +689,7 @@ class WatchCubit(
|
|||
lines: lines,
|
||||
parts: const {},
|
||||
selected: null,
|
||||
stretch: null,
|
||||
words: null,
|
||||
line: lastCueIndex(
|
||||
lines,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,461 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/furigana.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// The words of the line [line], each tappable, the kanji's readings above
|
||||
/// them; the line as text while it isn't cut yet. A line broken for showing
|
||||
/// over a picture is one here, wrapping where the width ends (Japanese and
|
||||
/// Chinese join as they are, others with a space).
|
||||
///
|
||||
/// Any stretch of its characters can be chosen by hand too (decided
|
||||
/// 2026-10-13: by characters, so also part of a word the cut took longer,
|
||||
/// 今日 out of 今日は): dragged across with a mouse, from the word or
|
||||
/// stretch chosen to a character Shift+clicked, or on a touch screen held
|
||||
/// on a character and dragged on. It is looked up as one word.
|
||||
class const LineWords({
|
||||
required final int line,
|
||||
required final double fontSize,
|
||||
final WrapAlignment alignment = WrapAlignment.center,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<LineWords> createState() => _LineWordsState();
|
||||
}
|
||||
|
||||
/// A part of the line as it shows, from `from` in the line so shown.
|
||||
typedef _Shown = ({int index, SentencePart part, int from});
|
||||
|
||||
class _LineWordsState() extends State<LineWords> {
|
||||
/// The stretch being dragged across, from the character it started on
|
||||
/// to the one under the pointer; null while none is.
|
||||
(int, int)? _dragging;
|
||||
|
||||
/// The parts as last built, and the line they make.
|
||||
List<_Shown> _shown = const [];
|
||||
String _text = '';
|
||||
|
||||
/// The character of the line under [global], if any.
|
||||
int? _characterAt(Offset global) {
|
||||
final result = HitTestResult();
|
||||
WidgetsBinding.instance.hitTestInView(
|
||||
result,
|
||||
global,
|
||||
View.of(context).viewId,
|
||||
);
|
||||
for (final entry in result.path) {
|
||||
final target = entry.target;
|
||||
if (target is _RenderCharacterRun) {
|
||||
return target.characterAt(global);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The stretch from [a] to [b], whichever comes first, as offsets.
|
||||
(int, int) _between(int a, int b) => (math.min(a, b), math.max(a, b));
|
||||
|
||||
void _choose((int, int) characters) {
|
||||
final (from, to) = characters;
|
||||
// To the end of the last character: past a pair of surrogates too.
|
||||
final end = to < _text.length && _isLead(_text.codeUnitAt(to))
|
||||
? to + 2
|
||||
: to + 1;
|
||||
context.read<WatchCubit>().selectStretch(
|
||||
widget.line,
|
||||
from,
|
||||
end,
|
||||
_text.substring(from, end),
|
||||
);
|
||||
}
|
||||
|
||||
void _start(Offset global) {
|
||||
if (_characterAt(global) case final at?) {
|
||||
setState(() => _dragging = (at, at));
|
||||
}
|
||||
}
|
||||
|
||||
void _move(Offset global) {
|
||||
final dragging = _dragging;
|
||||
if (dragging == null) {
|
||||
return;
|
||||
}
|
||||
if (_characterAt(global) case final at? when at != dragging.$2) {
|
||||
setState(() => _dragging = (dragging.$1, at));
|
||||
}
|
||||
}
|
||||
|
||||
void _end() {
|
||||
if (_dragging case (final from, final to)) {
|
||||
_choose(_between(from, to));
|
||||
}
|
||||
setState(() => _dragging = null);
|
||||
}
|
||||
|
||||
/// A click with Shift held: from the word or stretch chosen in this line
|
||||
/// (or the character itself) to the character clicked.
|
||||
void _shiftClick(PointerUpEvent event) {
|
||||
if (event.kind != PointerDeviceKind.mouse ||
|
||||
!HardwareKeyboard.instance.isShiftPressed ||
|
||||
_dragging != null) {
|
||||
return;
|
||||
}
|
||||
final at = _characterAt(event.position);
|
||||
if (at == null) {
|
||||
return;
|
||||
}
|
||||
final state = context.read<WatchCubit>().state;
|
||||
final anchor = switch ((state.stretch, state.selected)) {
|
||||
(final stretch?, _) when stretch.line == widget.line => stretch.start,
|
||||
(_, (:final line, :final part)) when line == widget.line =>
|
||||
_shown.where((shown) => shown.index == part).firstOrNull?.from ?? at,
|
||||
_ => at,
|
||||
};
|
||||
_choose(_between(anchor, at));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<WatchCubit>().state;
|
||||
final line = widget.line;
|
||||
final parts =
|
||||
state.parts[line] ??
|
||||
[SentencePart(text: state.lines[line].text, start: 0)];
|
||||
final joiner = switch (StudysetLanguageScope.of(
|
||||
context,
|
||||
).front?.split('-').first) {
|
||||
'ja' || 'zh' => '',
|
||||
_ => ' ',
|
||||
};
|
||||
var at = 0;
|
||||
_shown = [
|
||||
for (final (index, part) in parts.indexed)
|
||||
() {
|
||||
final shown = !part.isWord && part.text.contains('\n')
|
||||
? SentencePart(
|
||||
text: part.text.replaceAll('\n', joiner),
|
||||
start: part.start,
|
||||
)
|
||||
: part;
|
||||
final from = at;
|
||||
at += shown.text.length;
|
||||
return (index: index, part: shown, from: from);
|
||||
}(),
|
||||
];
|
||||
_text = [for (final shown in _shown) shown.part.text].join();
|
||||
// A stretch found as a word reads as that word while chosen: 今日 out
|
||||
// of 今日は as きょう, not as こんにち.
|
||||
final drawn = switch ((state.stretch, state.stretchPart, _dragging)) {
|
||||
(final stretch?, final found?, null)
|
||||
when stretch.line == line && found.isWord =>
|
||||
_cut(_shown, stretch, found),
|
||||
_ => _shown,
|
||||
};
|
||||
// What shows as chosen: the stretch being dragged, else the one
|
||||
// chosen in this line.
|
||||
final chosen = switch ((_dragging, state.stretch)) {
|
||||
((final a, final b), _) => () {
|
||||
final (from, to) = _between(a, b);
|
||||
return (from, to + 1);
|
||||
}(),
|
||||
(_, final stretch?) when stretch.line == line => (
|
||||
stretch.start,
|
||||
stretch.end,
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
final words = Wrap(
|
||||
alignment: widget.alignment,
|
||||
crossAxisAlignment: WrapCrossAlignment.end,
|
||||
children: [
|
||||
for (final (:index, :part, :from) in drawn)
|
||||
if (part.text.isNotEmpty)
|
||||
_Word(
|
||||
part: part,
|
||||
from: from,
|
||||
fontSize: widget.fontSize,
|
||||
known: part.entries.any(
|
||||
(entry) => state.known.contains(entry.ref),
|
||||
),
|
||||
selected:
|
||||
state.selected == (line: line, part: index) && part.isWord,
|
||||
chosen: switch (chosen) {
|
||||
(final start, final end)
|
||||
when start < from + part.text.length && end > from =>
|
||||
(start - from, end - from),
|
||||
_ => null,
|
||||
},
|
||||
onTap: part.isWord && index >= 0
|
||||
? () {
|
||||
// Shift+click chooses a stretch instead.
|
||||
if (!HardwareKeyboard.instance.isShiftPressed) {
|
||||
context.read<WatchCubit>().select(line, index);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
return Listener(
|
||||
onPointerUp: _shiftClick,
|
||||
// Touch: held on a character, then dragged on.
|
||||
child: GestureDetector(
|
||||
supportedDevices: const {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.stylus,
|
||||
PointerDeviceKind.invertedStylus,
|
||||
},
|
||||
onLongPressStart: (details) {
|
||||
_start(details.globalPosition);
|
||||
if (_dragging != null) {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
}
|
||||
},
|
||||
onLongPressMoveUpdate: (details) => _move(details.globalPosition),
|
||||
onLongPressEnd: (_) => _end(),
|
||||
onLongPressCancel: () => setState(() => _dragging = null),
|
||||
// A mouse: dragged across.
|
||||
child: GestureDetector(
|
||||
supportedDevices: const {PointerDeviceKind.mouse},
|
||||
// From the character the button went down on.
|
||||
dragStartBehavior: DragStartBehavior.down,
|
||||
onPanStart: (details) => _start(details.globalPosition),
|
||||
onPanUpdate: (details) => _move(details.globalPosition),
|
||||
onPanEnd: (_) => _end(),
|
||||
onPanCancel: () => setState(() => _dragging = null),
|
||||
child: words,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [shown] with the [stretch] chosen, [found] as a word, as one part of
|
||||
/// its own (not to be tapped: its index is -1), and what it cuts through
|
||||
/// as text before and after it.
|
||||
List<_Shown> _cut(
|
||||
List<_Shown> shown,
|
||||
WatchStretch stretch,
|
||||
SentencePart found,
|
||||
) {
|
||||
final cut = <_Shown>[];
|
||||
var placed = false;
|
||||
for (final each in shown) {
|
||||
final from = each.from;
|
||||
final to = from + each.part.text.length;
|
||||
if (to <= stretch.start || from >= stretch.end) {
|
||||
cut.add(each);
|
||||
continue;
|
||||
}
|
||||
if (from < stretch.start) {
|
||||
cut.add((
|
||||
index: -1,
|
||||
part: SentencePart(
|
||||
text: each.part.text.substring(0, stretch.start - from),
|
||||
start: each.part.start,
|
||||
),
|
||||
from: from,
|
||||
));
|
||||
}
|
||||
if (!placed) {
|
||||
placed = true;
|
||||
cut.add((
|
||||
index: -1,
|
||||
part: SentencePart(
|
||||
text: stretch.text,
|
||||
start: stretch.start,
|
||||
entries: found.entries,
|
||||
steps: found.steps,
|
||||
),
|
||||
from: stretch.start,
|
||||
));
|
||||
}
|
||||
if (to > stretch.end) {
|
||||
cut.add((
|
||||
index: -1,
|
||||
part: SentencePart(
|
||||
text: each.part.text.substring(stretch.end - from),
|
||||
start: each.part.start + stretch.end - from,
|
||||
),
|
||||
from: stretch.end,
|
||||
));
|
||||
}
|
||||
}
|
||||
return cut;
|
||||
}
|
||||
|
||||
/// Whether [unit] starts a pair of surrogates.
|
||||
bool _isLead(int unit) => unit >= 0xD800 && unit <= 0xDBFF;
|
||||
|
||||
/// A word of a line: its kanji with their readings above, tappable, marked
|
||||
/// when the studyset holds it ([known]) or it is chosen ([selected]); or
|
||||
/// the text between words. Its characters within a stretch chosen by hand
|
||||
/// ([chosen], from and to within it) are marked one by one.
|
||||
class const _Word({
|
||||
required final SentencePart part,
|
||||
required final int from,
|
||||
required final double fontSize,
|
||||
required final bool known,
|
||||
required final bool selected,
|
||||
required final (int, int)? chosen,
|
||||
required final VoidCallback? onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final entry = part.entries.firstOrNull;
|
||||
final pieces = entry == null
|
||||
? [(text: part.text, reading: null)]
|
||||
: furigana(part.text, entry.headword, entry.reading);
|
||||
final text = TextStyle(fontSize: fontSize, height: 1.2);
|
||||
final reading = TextStyle(
|
||||
fontSize: fontSize * 0.45,
|
||||
height: 1.1,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final marked = theme.colorScheme.primaryContainer;
|
||||
var at = 0;
|
||||
final word = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final piece in pieces)
|
||||
() {
|
||||
final start = at;
|
||||
at += piece.text.length;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(piece.reading ?? '', style: reading),
|
||||
_CharacterRun(
|
||||
start: from + start,
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
children: _marked(piece.text, start, chosen, marked),
|
||||
),
|
||||
style: text,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}(),
|
||||
],
|
||||
);
|
||||
if (onTap == null) {
|
||||
return word;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: Material(
|
||||
color: selected
|
||||
? theme.colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
width: 2,
|
||||
color: known
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: word,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// [text], from [start] in the word, as spans: what lies within [chosen]
|
||||
/// on [colour], the rest as it is.
|
||||
static List<TextSpan> _marked(
|
||||
String text,
|
||||
int start,
|
||||
(int, int)? chosen,
|
||||
Color colour,
|
||||
) {
|
||||
if (chosen == null) {
|
||||
return [TextSpan(text: text)];
|
||||
}
|
||||
final (from, to) = chosen;
|
||||
final inFrom = (from - start).clamp(0, text.length);
|
||||
final inTo = (to - start).clamp(0, text.length);
|
||||
return [
|
||||
if (inFrom > 0) TextSpan(text: text.substring(0, inFrom)),
|
||||
if (inTo > inFrom)
|
||||
TextSpan(
|
||||
text: text.substring(inFrom, inTo),
|
||||
style: TextStyle(backgroundColor: colour),
|
||||
),
|
||||
if (inTo < text.length) TextSpan(text: text.substring(inTo)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/// Characters of a line from [start] in it, whose paragraph (the [child])
|
||||
/// says which of them lies under the pointer.
|
||||
class const _CharacterRun({required final int start, required super.child})
|
||||
extends SingleChildRenderObjectWidget {
|
||||
@override
|
||||
_RenderCharacterRun createRenderObject(BuildContext context) =>
|
||||
_RenderCharacterRun(start);
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_RenderCharacterRun renderObject,
|
||||
) => renderObject.start = start;
|
||||
}
|
||||
|
||||
class _RenderCharacterRun(var int start) extends RenderProxyBox {
|
||||
/// The character of the line at [global], within this run: the one
|
||||
/// whose box holds it, or the nearest across.
|
||||
int? characterAt(Offset global) {
|
||||
final paragraph = child;
|
||||
if (paragraph is! RenderParagraph) {
|
||||
return null;
|
||||
}
|
||||
final local = paragraph.globalToLocal(global);
|
||||
final text = paragraph.text.toPlainText();
|
||||
int? nearest;
|
||||
var distance = double.infinity;
|
||||
for (var offset = 0; offset < text.length; offset++) {
|
||||
if (_isTrail(text.codeUnitAt(offset))) {
|
||||
continue;
|
||||
}
|
||||
final length = _isLead(text.codeUnitAt(offset)) ? 2 : 1;
|
||||
for (final box in paragraph.getBoxesForSelection(
|
||||
TextSelection(baseOffset: offset, extentOffset: offset + length),
|
||||
)) {
|
||||
final away = local.dx < box.left
|
||||
? box.left - local.dx
|
||||
: local.dx > box.right
|
||||
? local.dx - box.right
|
||||
: 0.0;
|
||||
if (away < distance) {
|
||||
distance = away;
|
||||
nearest = offset;
|
||||
}
|
||||
}
|
||||
}
|
||||
return nearest == null ? null : start + nearest;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether [unit] ends a pair of surrogates.
|
||||
bool _isTrail(int unit) => unit >= 0xDC00 && unit <= 0xDFFF;
|
||||
|
|
@ -15,8 +15,6 @@ import 'package:halkyon_learn_flutter/src/feature/card/widget/vocabulary_senses.
|
|||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_adder.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/entry_progress.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/furigana.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/sentence_words.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_word_tile.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/studyset_dictionary.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/bloc/watch_cubit.dart';
|
||||
|
|
@ -25,6 +23,7 @@ import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/jellyfin.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/model/word_filter.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/ambient_glow.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/jellyfin_connect.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/line_words.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/jellyfin/widget/watch_player.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/study/model/card_schedule.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||
|
|
@ -369,7 +368,7 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
|
|||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_LineWords(line: line, fontSize: fontSize),
|
||||
LineWords(line: line, fontSize: fontSize),
|
||||
if (translation != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
|
|
@ -385,60 +384,6 @@ class const _Line({required final double fontSize}) extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// The words of the line [line], each tappable, the kanji's readings above
|
||||
/// them; the line as text while it isn't cut yet. A line broken for showing
|
||||
/// over a picture is one here, wrapping where the width ends (Japanese and
|
||||
/// Chinese join as they are, others with a space).
|
||||
class const _LineWords({
|
||||
required final int line,
|
||||
required final double fontSize,
|
||||
final WrapAlignment alignment = WrapAlignment.center,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = context.watch<WatchCubit>().state;
|
||||
final parts =
|
||||
state.parts[line] ??
|
||||
[SentencePart(text: state.lines[line].text, start: 0)];
|
||||
final joiner = switch (StudysetLanguageScope.of(
|
||||
context,
|
||||
).front?.split('-').first) {
|
||||
'ja' || 'zh' => '',
|
||||
_ => ' ',
|
||||
};
|
||||
final row = [
|
||||
for (final (index, part) in parts.indexed)
|
||||
if (!part.isWord && part.text.contains('\n'))
|
||||
(
|
||||
index,
|
||||
SentencePart(text: part.text.replaceAll('\n', joiner), start: 0),
|
||||
)
|
||||
else
|
||||
(index, part),
|
||||
];
|
||||
return Wrap(
|
||||
alignment: alignment,
|
||||
crossAxisAlignment: WrapCrossAlignment.end,
|
||||
children: [
|
||||
for (final (index, part) in row)
|
||||
if (part.text.isNotEmpty)
|
||||
_Word(
|
||||
part: part,
|
||||
fontSize: fontSize,
|
||||
known: part.entries.any(
|
||||
(entry) => state.known.contains(entry.ref),
|
||||
),
|
||||
selected:
|
||||
state.selected == (line: line, part: index) && part.isWord,
|
||||
onTap: part.isWord
|
||||
? () => context.read<WatchCubit>().select(line, index)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A song: its cover as the player's picture, square, its colours glowing
|
||||
/// around it in the dark theme, and its lyrics beside it on a desktop
|
||||
/// (below it on a phone) as a moving banner (decided 2026-10-13).
|
||||
|
|
@ -589,7 +534,7 @@ class _LyricsBannerState() extends State<_LyricsBanner> {
|
|||
onTap: state.synced
|
||||
? () => context.read<WatchCubit>().playLine(index)
|
||||
: null,
|
||||
child: _LineWords(
|
||||
child: LineWords(
|
||||
line: index,
|
||||
fontSize: sung == index ? big : small,
|
||||
alignment: widget.wide
|
||||
|
|
@ -605,76 +550,6 @@ class _LyricsBannerState() extends State<_LyricsBanner> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A word of a line: its kanji with their readings above, tappable, marked
|
||||
/// when the studyset holds it ([known]) or it is chosen; or the text
|
||||
/// between words.
|
||||
class const _Word({
|
||||
required final SentencePart part,
|
||||
required final double fontSize,
|
||||
required final bool known,
|
||||
required final bool selected,
|
||||
required final VoidCallback? onTap,
|
||||
}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final entry = part.entries.firstOrNull;
|
||||
final pieces = entry == null
|
||||
? [(text: part.text, reading: null)]
|
||||
: furigana(part.text, entry.headword, entry.reading);
|
||||
final text = TextStyle(fontSize: fontSize, height: 1.2);
|
||||
final reading = TextStyle(
|
||||
fontSize: fontSize * 0.45,
|
||||
height: 1.1,
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
);
|
||||
final word = Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
for (final piece in pieces)
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(piece.reading ?? '', style: reading),
|
||||
Text(piece.text, style: text),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
if (onTap == null) {
|
||||
return word;
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 1),
|
||||
child: Material(
|
||||
color: selected
|
||||
? theme.colorScheme.primaryContainer
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
width: 2,
|
||||
color: known
|
||||
? theme.colorScheme.primary
|
||||
: theme.colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: word,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Beside the video: the word chosen, or the video's words; the sources
|
||||
/// of the words pinned in its lower right corner (decided 2026-10-13).
|
||||
class const _Panel({
|
||||
|
|
@ -694,7 +569,7 @@ class const _Panel({
|
|||
);
|
||||
}
|
||||
final selected = context.select<WatchCubit, bool>(
|
||||
(cubit) => cubit.state.selectedPart != null,
|
||||
(cubit) => cubit.state.chosenLine != null,
|
||||
);
|
||||
return _VideoTint(
|
||||
colour: colour,
|
||||
|
|
@ -820,12 +695,71 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
final theme = Theme.of(context);
|
||||
WatchCubit cubit() => context.read<WatchCubit>();
|
||||
final state = context.watch<WatchCubit>().state;
|
||||
final part = state.selectedPart;
|
||||
final selected = state.selected;
|
||||
if (part == null || selected == null) {
|
||||
final line = state.chosenLine;
|
||||
if (line == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final translation = state.translationOf(selected.line);
|
||||
final part = state.selectedPart;
|
||||
final translation = state.translationOf(line);
|
||||
final heading = Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
part?.text ?? state.stretch?.text ?? '',
|
||||
style: theme.textTheme.headlineSmall,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => cubit().unselect(),
|
||||
),
|
||||
],
|
||||
);
|
||||
final lineAndTranslation = [
|
||||
Text(oneLine(state.lines[line].text)),
|
||||
if (translation != null)
|
||||
Text(
|
||||
oneLine(translation),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
];
|
||||
// A stretch chosen by hand while it is looked up, if that failed, or
|
||||
// when the dictionary has no such word: Jisho may still know it.
|
||||
if (part == null || !part.isWord) {
|
||||
final text = part?.text ?? state.stretch?.text ?? '';
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 16),
|
||||
children: [
|
||||
heading,
|
||||
...lineAndTranslation,
|
||||
const SizedBox(height: 16),
|
||||
if (part == null && !state.stretchFailed)
|
||||
const Center(child: AppLoadingIndicator())
|
||||
else ...[
|
||||
Text(
|
||||
state.stretchFailed
|
||||
? l10n.watchStretchFailed
|
||||
: l10n.watchStretchNone(text),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.externalLink, size: 16),
|
||||
label: Text(l10n.watchStretchJisho),
|
||||
onPressed: () => launchUrl(
|
||||
Uri.https('jisho.org', '/search/$text'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
// On the headword's line, at its end (decided 2026-10-13): Jisho, and
|
||||
// Add for a word the studyset lacks.
|
||||
List<Widget> actions(DictionaryWord entry) => [
|
||||
|
|
@ -865,18 +799,7 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 16),
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(part.text, style: theme.textTheme.headlineSmall),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).closeButtonTooltip,
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => cubit().unselect(),
|
||||
),
|
||||
],
|
||||
),
|
||||
heading,
|
||||
if (part.steps.isNotEmpty)
|
||||
Text(
|
||||
l10n.watchFormOf(part.entries.first.headword),
|
||||
|
|
@ -885,14 +808,7 @@ class const _ChosenWord({required final Dictionary dictionary})
|
|||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(oneLine(state.lines[selected.line].text)),
|
||||
if (translation != null)
|
||||
Text(
|
||||
oneLine(translation),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
...lineAndTranslation,
|
||||
const SizedBox(height: 8),
|
||||
for (final (index, entry) in part.entries.indexed) ...[
|
||||
if (index > 0) const Divider(height: 1),
|
||||
|
|
|
|||
|
|
@ -212,6 +212,62 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('a stretch chosen by hand', () {
|
||||
final greetings = [
|
||||
word('30', '今日', reading: 'きょう', pos: ['n', 'adv']),
|
||||
word('31', '今日は', reading: 'こんにちは', pos: ['int']),
|
||||
word('32', 'は', pos: ['prt']),
|
||||
...words,
|
||||
];
|
||||
|
||||
test('is looked up whole, also inside a word the cut took longer: 今日 '
|
||||
'out of 今日は', () async {
|
||||
final repository = DictionaryRepository(
|
||||
FakeDictionaryGateway(words: greetings),
|
||||
);
|
||||
final [line] = await repository.wordsOf(jmdict, ['今日は']);
|
||||
expect(line.single.entries.first.headword, '今日は');
|
||||
|
||||
final chosen = await repository.wordOfStretch(jmdict, '今日');
|
||||
|
||||
expect(chosen.text, '今日');
|
||||
expect(chosen.start, 0);
|
||||
expect(chosen.entries.single.headword, '今日');
|
||||
});
|
||||
|
||||
test('as it stands or as a form of a word: 会い of 会う', () async {
|
||||
final repository = DictionaryRepository(
|
||||
FakeDictionaryGateway(words: greetings),
|
||||
);
|
||||
|
||||
final chosen = await repository.wordOfStretch(jmdict, '会い', start: 8);
|
||||
|
||||
// A stem goes back to its word in no step of its own.
|
||||
expect(chosen.entries.first.headword, '会う');
|
||||
expect(chosen.start, 8);
|
||||
});
|
||||
|
||||
test('is nothing the dictionary has when it is no word, or holds '
|
||||
'punctuation', () async {
|
||||
final repository = DictionaryRepository(
|
||||
FakeDictionaryGateway(words: greetings),
|
||||
);
|
||||
|
||||
expect((await repository.wordOfStretch(jmdict, '駅で')).isWord, isFalse);
|
||||
expect((await repository.wordOfStretch(jmdict, '昨日、駅')).isWord, isFalse);
|
||||
});
|
||||
|
||||
test('is asked for once', () async {
|
||||
final gateway = FakeDictionaryGateway(words: greetings);
|
||||
final repository = DictionaryRepository(gateway);
|
||||
|
||||
await repository.wordOfStretch(jmdict, '今日');
|
||||
await repository.wordOfStretch(jmdict, '今日');
|
||||
|
||||
expect(gateway.keysAsked, hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
group('furigana', () {
|
||||
test('reads the kanji of a word as its dictionary form gives them', () {
|
||||
expect(furigana('食べられなかった', '食べる', 'たべる'), [
|
||||
|
|
|
|||
|
|
@ -600,6 +600,98 @@ void main() {
|
|||
expect(card.content.entry, (source: 'jmdict', id: '37'));
|
||||
});
|
||||
|
||||
group('a stretch chosen by hand (decided 2026-10-13: by characters)', () {
|
||||
final greetings = [
|
||||
word('40', '今日', reading: 'きょう', pos: ['n'], glosses: ['today']),
|
||||
word('41', '今日は', reading: 'こんにちは', pos: ['int'], glosses: ['hello']),
|
||||
word('42', 'は', pos: ['prt'], labels: ['common']),
|
||||
word('43', '雨', reading: 'あめ', pos: ['n'], glosses: ['rain']),
|
||||
word('44', 'だ', pos: ['cop'], labels: ['common']),
|
||||
];
|
||||
Future<void> watchGreetings(WidgetTester tester) async {
|
||||
await watch(
|
||||
tester,
|
||||
vtt: {
|
||||
('episode-1', 2): 'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\n今日は雨だ\n',
|
||||
('episode-1', 3):
|
||||
'WEBVTT\n\n00:00:01.000 --> 00:00:05.000\nA grey sky.\n',
|
||||
},
|
||||
words: greetings,
|
||||
);
|
||||
await playTo(tester, const Duration(seconds: 2));
|
||||
}
|
||||
|
||||
/// The middle of the [index]th character of the base text [text] of
|
||||
/// the line (four characters to a run at most here, each as wide).
|
||||
Offset character(WidgetTester tester, String text, int index) {
|
||||
final rect = tester.getRect(find.text(text, findRichText: true).first);
|
||||
final width = rect.width / text.length;
|
||||
return Offset(rect.left + width * (index + 0.5), rect.center.dy);
|
||||
}
|
||||
|
||||
testWidgets('dragged across with a mouse, the characters chosen are '
|
||||
'looked up as one word, also inside a word the cut took longer: '
|
||||
'今日 out of 今日は', (tester) async {
|
||||
await watchGreetings(tester);
|
||||
await players.last!.play();
|
||||
expect(find.text('今日', findRichText: true), findsOneWidget);
|
||||
|
||||
final drag = await tester.startGesture(
|
||||
character(tester, '今日', 0),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await drag.moveTo(character(tester, '今日', 1));
|
||||
await drag.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(players.last!.state.value.playing, isFalse);
|
||||
expect(find.textContaining('today', findRichText: true), findsOneWidget);
|
||||
expect(find.textContaining('hello', findRichText: true), findsNothing);
|
||||
// While chosen, the line reads it as the word chosen: きょう, not
|
||||
// こんにち of こんにちは.
|
||||
expect(find.text('こんにち'), findsNothing);
|
||||
expect(find.text('きょう'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('Shift+click chooses from the word chosen to the character '
|
||||
'clicked; what the dictionary lacks as one word is searched on '
|
||||
'Jisho', (tester) async {
|
||||
await watchGreetings(tester);
|
||||
await tester.tap(find.text('雨', findRichText: true));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.textContaining('rain', findRichText: true), findsOneWidget);
|
||||
|
||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.shiftLeft);
|
||||
final click = await tester.startGesture(
|
||||
character(tester, 'だ', 0),
|
||||
kind: PointerDeviceKind.mouse,
|
||||
);
|
||||
await click.up();
|
||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.shiftLeft);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text("雨だ isn't in the dictionary as one word."),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Search Jisho for it'), findsOneWidget);
|
||||
expect(find.textContaining('rain', findRichText: true), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('on a phone, holding a character starts a stretch, and '
|
||||
'moving on extends it', (tester) async {
|
||||
await watchGreetings(tester);
|
||||
|
||||
final hold = await tester.startGesture(character(tester, '今日', 0));
|
||||
await tester.pump(const Duration(milliseconds: 600));
|
||||
await hold.moveTo(character(tester, '今日', 1));
|
||||
await hold.up();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('today', findRichText: true), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
testWidgets("the episode's words can be studied or crammed: the cards of "
|
||||
'its words, held before it was watched too, get its tag', (tester) async {
|
||||
final station = sampleWords.firstWhere((word) => word.headword == '駅');
|
||||
|
|
|
|||
Loading…
Reference in a new issue