Skip to content

Commit

Permalink
Merge pull request #50 from solid-software/feat/intelligent-highlight-2
Browse files Browse the repository at this point in the history
Feat/Intelligent highlight
  • Loading branch information
solid-danylokhvan authored Jul 7, 2023
2 parents 0224ec7 + 710155f commit 87ea769
Show file tree
Hide file tree
Showing 7 changed files with 158 additions and 12 deletions.
93 changes: 86 additions & 7 deletions lib/core/controllers/colored_text_editing_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import 'package:languagetool_textfield/core/enums/mistake_type.dart';
import 'package:languagetool_textfield/domain/highlight_style.dart';
import 'package:languagetool_textfield/domain/language_check_service.dart';
import 'package:languagetool_textfield/domain/mistake.dart';
import 'package:languagetool_textfield/utils/closed_range.dart';
import 'package:languagetool_textfield/utils/keep_latest_response_service.dart';
import 'package:languagetool_textfield/utils/mistake_popup.dart';

/// A TextEditingController with overrides buildTextSpan for building
Expand All @@ -18,6 +20,10 @@ class ColoredTextEditingController extends TextEditingController {
/// Language tool API index
final LanguageCheckService languageCheckService;

/// Create an instance of [KeepLatestResponseService]
/// to handle asynchronous operations
final latestResponseService = KeepLatestResponseService();

/// List which contains Mistake objects spans are built from
List<Mistake> _mistakes = [];

Expand Down Expand Up @@ -75,8 +81,10 @@ class ColoredTextEditingController extends TextEditingController {

/// Replaces mistake with given replacement
void replaceMistake(Mistake mistake, String replacement) {
final mistakes = List<Mistake>.from(_mistakes);
mistakes.remove(mistake);
_mistakes = mistakes;
text = text.replaceRange(mistake.offset, mistake.endOffset, replacement);
_mistakes.remove(mistake);
focusNode?.requestFocus();
Future.microtask.call(() {
final newOffset = mistake.offset + replacement.length;
Expand All @@ -89,24 +97,29 @@ class ColoredTextEditingController extends TextEditingController {
Future<void> _handleTextChange(String newText) async {
///set value triggers each time, even when cursor changes its location
///so this check avoid cleaning Mistake list when text wasn't really changed
if (newText == text) return;
if (newText == text || newText.isEmpty) return;

final filteredMistakes = _filterMistakesOnChanged(newText);
_mistakes = filteredMistakes.toList();

// If we have a text change and we have a popup on hold
// it will close the popup
_closePopup();

_mistakes.clear();
for (final recognizer in _recognizers) {
recognizer.dispose();
}
_recognizers.clear();

final mistakesWrapper = await languageCheckService.findMistakes(newText);
final mistakesWrapper = await latestResponseService.processLatestOperation(
() => languageCheckService.findMistakes(newText),
);
final mistakes = mistakesWrapper?.result();
_fetchError = mistakesWrapper?.error;

_mistakes =
mistakesWrapper.hasResult ? mistakesWrapper.result().toList() : [];
_fetchError = mistakesWrapper.error;
if (mistakes == null) return;

_mistakes = mistakes;
notifyListeners();
}

Expand All @@ -118,6 +131,9 @@ class ColoredTextEditingController extends TextEditingController {
int currentOffset = 0; // enter index

for (final Mistake mistake in _mistakes) {
final mistakeEndOffset = min(mistake.endOffset, text.length);
if (mistake.offset > mistakeEndOffset) continue;

/// TextSpan before mistake
yield TextSpan(
text: text.substring(
Expand Down Expand Up @@ -190,6 +206,69 @@ class ColoredTextEditingController extends TextEditingController {
);
}

/// Filters the list of mistakes based on the changes
/// in the text when it is changed.
Iterable<Mistake> _filterMistakesOnChanged(String newText) sync* {
final isSelectionRangeEmpty = selection.end == selection.start;
final lengthDiscrepancy = newText.length - text.length;

for (final mistake in _mistakes) {
Mistake? newMistake;

newMistake = isSelectionRangeEmpty
? _adjustMistakeOffsetWithCaretCursor(
mistake: mistake,
lengthDiscrepancy: lengthDiscrepancy,
)
: _adjustMistakeOffsetWithSelectionRange(
mistake: mistake,
lengthDiscrepancy: lengthDiscrepancy,
);

if (newMistake != null) yield newMistake;
}
}

/// Adjusts the mistake offset when the selection is a caret cursor.
Mistake? _adjustMistakeOffsetWithCaretCursor({
required Mistake mistake,
required int lengthDiscrepancy,
}) {
final mistakeRange = ClosedRange(mistake.offset, mistake.endOffset);
final caretLocation = selection.base.offset;

// Don't highlight mistakes on changed text
// until we get an update from the API.
final isCaretOnMistake = mistakeRange.contains(caretLocation);
if (isCaretOnMistake) return null;

final shouldAdjustOffset = mistakeRange.isBeforeOrAt(caretLocation);
if (!shouldAdjustOffset) return mistake;

final newOffset = mistake.offset + lengthDiscrepancy;

return mistake.copyWith(offset: newOffset);
}

/// Adjusts the mistake offset when the selection is a range.
Mistake? _adjustMistakeOffsetWithSelectionRange({
required Mistake mistake,
required int lengthDiscrepancy,
}) {
final selectionRange = ClosedRange(selection.start, selection.end);
final mistakeRange = ClosedRange(mistake.offset, mistake.endOffset);

final hasSelectedTextChanged = selectionRange.overlapsWith(mistakeRange);
if (hasSelectedTextChanged) return null;

final shouldAdjustOffset = selectionRange.isAfterOrAt(mistake.offset);
if (!shouldAdjustOffset) return mistake;

final newOffset = mistake.offset + lengthDiscrepancy;

return mistake.copyWith(offset: newOffset);
}

/// Returns color for mistake TextSpan style
Color _getMistakeColor(MistakeType type) {
switch (type) {
Expand Down
17 changes: 17 additions & 0 deletions lib/domain/mistake.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,21 @@ class Mistake {
required this.length,
this.replacements = const [],
});

/// Creates a copy of this mistake with optional parameter values overridden.
Mistake copyWith({
String? message,
MistakeType? type,
int? offset,
int? length,
List<String>? replacements,
}) {
return Mistake(
message: message ?? this.message,
type: type ?? this.type,
offset: offset ?? this.offset,
length: length ?? this.length,
replacements: replacements ?? this.replacements,
);
}
}
8 changes: 4 additions & 4 deletions lib/implementations/debounce_lang_tool_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ class DebounceLangToolService extends LanguageCheckService {

@override
Future<Result<List<Mistake>>> findMistakes(String text) async {
final value = await debouncing.debounce(() {
return baseService.findMistakes(text);
}) as Result<List<Mistake>>?;
final value =
await debouncing.debounce(() => baseService.findMistakes(text))
as Result<List<Mistake>>;

return value ?? const Result.success(<Mistake>[]);
return value;
}

@override
Expand Down
2 changes: 1 addition & 1 deletion lib/implementations/lang_tool_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class LangToolService extends LanguageCheckService {
final LanguageToolClient languageTool;

/// Creates a new instance of the [LangToolService].
const LangToolService(this.languageTool);
LangToolService(this.languageTool);

@override
Future<Result<List<Mistake>>> findMistakes(String text) async {
Expand Down
31 changes: 31 additions & 0 deletions lib/utils/closed_range.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import 'dart:math';

/// The [ClosedRange] class represents a closed range of integers, defined
/// by a starting point and an ending point.
/// The start and end properties represent the bounds of the range.
class ClosedRange {
/// The start of the closed range.
final int start;

/// The start of the closed range.
final int end;

/// Constructor for creating a ClosedRange object
const ClosedRange(this.start, this.end);

/// Checks if the given point is before or at the range
bool isBeforeOrAt(int point) => point <= min(start, end);

/// Checks if the given point is after or at the range
bool isAfterOrAt(int point) => point >= max(start, end);

/// Checks if the range contains the given point
bool contains(int point) {
return start <= point && point <= end;
}

/// Checks if the this range is within the boundaries of the another range
bool overlapsWith(ClosedRange other) {
return contains(other.start) || contains(other.end);
}
}
18 changes: 18 additions & 0 deletions lib/utils/keep_latest_response_service.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import 'package:async/async.dart';

/// A service that executes asynchronous operations and ensures that only
/// the results of the latest operation are returned.
class KeepLatestResponseService {
CancelableOperation<dynamic>? _currentOperation;

/// Executes the latest operation and returns its result.
/// Only the results of the most recent operation are returned,
/// discarding any previous ongoing operations.
Future<T?> processLatestOperation<T>(Future<T> Function() action) async {
final newOperation = CancelableOperation<T>.fromFuture(action());
await _currentOperation?.cancel();
_currentOperation = newOperation;

return _currentOperation?.value as Future<T>?;
}
}
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ environment:
flutter: ">=1.17.0"

dependencies:
async: ^2.11.0
collection: ^1.17.1
flutter:
sdk: flutter
Expand Down

0 comments on commit 87ea769

Please sign in to comment.