From 829ec201eb04ef9619e88d10cf156275a2ad17aa Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 7 Dec 2022 21:52:37 +0100 Subject: [PATCH] overlay text diff animation for rating/tags --- lib/theme/colors.dart | 2 +- lib/theme/durations.dart | 3 + lib/utils/diff_match.dart | 1495 +++++++++++++++++ lib/widgets/about/translators.dart | 2 +- .../common/action_mixins/feedback.dart | 4 +- .../common/basic/text/animated_diff.dart | 276 +++ .../background_painter.dart} | 0 .../change_highlight.dart} | 10 +- .../outlined.dart} | 0 lib/widgets/common/identity/aves_caption.dart | 4 +- .../common/identity/highlight_title.dart | 2 +- .../common/map/leaflet/scale_layer.dart | 2 +- .../entry_editors/remove_metadata_dialog.dart | 2 +- .../settings/video/subtitle_sample.dart | 4 +- .../viewer/overlay/details/rating_tags.dart | 22 +- .../viewer/visual/subtitle/subtitle.dart | 4 +- 16 files changed, 1809 insertions(+), 23 deletions(-) create mode 100644 lib/utils/diff_match.dart create mode 100644 lib/widgets/common/basic/text/animated_diff.dart rename lib/widgets/common/basic/{text_background_painter.dart => text/background_painter.dart} (100%) rename lib/widgets/common/basic/{animated_text.dart => text/change_highlight.dart} (85%) rename lib/widgets/common/basic/{outlined_text.dart => text/outlined.dart} (100%) diff --git a/lib/theme/colors.dart b/lib/theme/colors.dart index 9782413e0..357067b44 100644 --- a/lib/theme/colors.dart +++ b/lib/theme/colors.dart @@ -44,7 +44,7 @@ abstract class AvesColorsData { Color fromString(String string) { var color = _stringColors[string]; if (color == null) { - final hash = string.codeUnits.fold(0, (prev, el) => prev = el + ((prev << 5) - prev)); + final hash = string.codeUnits.fold(0, (prev, v) => prev = v + ((prev << 5) - prev)); final hue = (hash % 360).toDouble(); color = fromHue(hue); _stringColors[string] = color; diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index a055b765a..0bf51f1ea 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -98,6 +98,7 @@ class DurationsData { final Duration expansionTileAnimation; final Duration formTransition; final Duration formTextStyleTransition; + final Duration textDiffAnimation; final Duration chartTransition; final Duration iconAnimation; final Duration staggeredAnimation; @@ -116,6 +117,7 @@ class DurationsData { this.expansionTileAnimation = const Duration(milliseconds: 200), this.formTransition = const Duration(milliseconds: 200), this.formTextStyleTransition = const Duration(milliseconds: 800), + this.textDiffAnimation = const Duration(milliseconds: 150), this.chartTransition = const Duration(milliseconds: 400), this.iconAnimation = const Duration(milliseconds: 300), this.staggeredAnimation = const Duration(milliseconds: 375), @@ -132,6 +134,7 @@ class DurationsData { expansionTileAnimation: const Duration(microseconds: 1), formTransition: Duration.zero, formTextStyleTransition: Duration.zero, + textDiffAnimation: Duration.zero, chartTransition: Duration.zero, iconAnimation: Duration.zero, staggeredAnimation: Duration.zero, diff --git a/lib/utils/diff_match.dart b/lib/utils/diff_match.dart new file mode 100644 index 000000000..29042c4de --- /dev/null +++ b/lib/utils/diff_match.dart @@ -0,0 +1,1495 @@ +/* + * Diff Match and Patch + * Copyright 2018 The diff-match-patch Authors. + * https://github.com/google/diff-match-patch + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Functions for diff, match and patch. + * Computes the difference between two texts to create a patch. + * Applies the patch onto another text, allowing for errors. + * + * @author fraser@google.com (Neil Fraser) + */ + +// ignore_for_file: no_leading_underscores_for_local_identifiers +// ignore_for_file: non_constant_identifier_names +// ignore_for_file: unnecessary_this + +import 'dart:collection'; +import 'dart:math'; + +// adapted from Google's `Diff Match and Patch` Dart implementation +// cf https://github.com/google/diff-match-patch/tree/master/dart + +/// Class representing one diff operation. +class Diff { + /// One of: Operation.insert, Operation.delete or Operation.equal. + Operation operation; + + /// The text associated with this diff operation. + String text; + + /// Constructor. Initializes the diff with the provided values. + /// [operation] is one of Operation.insert, Operation.delete or Operation.equal. + /// [text] is the text being applied. + Diff(this.operation, this.text); + + /// Display a human-readable version of this Diff. + /// Returns a text version. + @override + String toString() { + String prettyText = this.text.replaceAll('\n', '\u00b6'); + return 'Diff(${this.operation},"$prettyText")'; + } + + /// Is this Diff equivalent to another Diff? + /// [other] is another Diff to compare against. + /// Returns true or false. + @override + bool operator ==(Object other) => identical(this, other) || other is Diff && runtimeType == other.runtimeType && operation == other.operation && text == other.text; + + /// Generate a uniquely identifiable hashcode for this Diff. + /// Returns numeric hashcode. + @override + int get hashCode => operation.hashCode ^ text.hashCode; +} + +/// The data structure representing a diff is a List of Diff objects: +/// {Diff(Operation.delete, 'Hello'), Diff(Operation.insert, 'Goodbye'), +/// Diff(Operation.equal, ' world.')} +/// which means: delete 'Hello', add 'Goodbye' and keep ' world.' +enum Operation { delete, insert, equal } + +/// Class containing the diff, match and patch methods. +/// Also contains the behaviour settings. +class DiffMatchPatch { + // Defaults. + // Set these on your diff_match_patch instance to override the defaults. + + /// Number of seconds to map a diff before giving up (0 for infinity). + double Diff_Timeout = 1.0; + + /// Cost of an empty edit operation in terms of edit characters. + int Diff_EditCost = 4; + + /// At what point is no match declared (0.0 = perfection, 1.0 = very loose). + double Match_Threshold = 0.5; + + /// How far to search for a match (0 = exact location, 1000+ = broad match). + /// A match this many characters away from the expected location will add + /// 1.0 to the score (0.0 is a perfect match). + int Match_Distance = 1000; + + /// When deleting a large block of text (over ~64 characters), how close do + /// the contents have to be to match the expected contents. (0.0 = perfection, + /// 1.0 = very loose). Note that Match_Threshold controls how closely the + /// end points of a delete need to match. + double Patch_DeleteThreshold = 0.5; + + /// Chunk size for context length. + int Patch_Margin = 4; + + /// The number of bits in an int. + int Match_MaxBits = 32; + + // DIFF FUNCTIONS + + /// Find the differences between two texts. Simplifies the problem by + /// stripping any common prefix or suffix off the texts before diffing. + /// [text1] is the old string to be diffed. + /// [text2] is the new string to be diffed. + /// [checklines] is an optional speedup flag. If present and false, then don't + /// run a line-level diff first to identify the changed areas. + /// Defaults to true, which does a faster, slightly less optimal diff. + /// [deadline] is an optional time when the diff should be complete by. Used + /// internally for recursive calls. Users should set DiffTimeout instead. + /// Returns a List of Diff objects. + List diff_main(String? text1, String? text2, [bool checklines = true, DateTime? deadline]) { + // Set a deadline by which time the diff must be complete. + if (deadline == null) { + deadline = DateTime.now(); + if (Diff_Timeout <= 0) { + // One year should be sufficient for 'infinity'. + deadline = deadline.add(const Duration(days: 365)); + } else { + deadline = deadline.add(Duration(milliseconds: (Diff_Timeout * 1000).toInt())); + } + } + // Check for null inputs. + if (text1 == null || text2 == null) { + throw ArgumentError('Null inputs. (diff_main)'); + } + + // Check for equality (speedup). + List diffs; + if (text1 == text2) { + diffs = []; + if (text1.isNotEmpty) { + diffs.add(Diff(Operation.equal, text1)); + } + return diffs; + } + + // Trim off common prefix (speedup). + int commonlength = diff_commonPrefix(text1, text2); + String commonprefix = text1.substring(0, commonlength); + text1 = text1.substring(commonlength); + text2 = text2.substring(commonlength); + + // Trim off common suffix (speedup). + commonlength = diff_commonSuffix(text1, text2); + String commonsuffix = text1.substring(text1.length - commonlength); + text1 = text1.substring(0, text1.length - commonlength); + text2 = text2.substring(0, text2.length - commonlength); + + // Compute the diff on the middle block. + diffs = _diff_compute(text1, text2, checklines, deadline); + + // Restore the prefix and suffix. + if (commonprefix.isNotEmpty) { + diffs.insert(0, Diff(Operation.equal, commonprefix)); + } + if (commonsuffix.isNotEmpty) { + diffs.add(Diff(Operation.equal, commonsuffix)); + } + + diff_cleanupMerge(diffs); + return diffs; + } + + /// Find the differences between two texts. Assumes that the texts do not + /// have any common prefix or suffix. + /// [text1] is the old string to be diffed. + /// [text2] is the new string to be diffed. + /// [checklines] is a speedup flag. If false, then don't run a + /// line-level diff first to identify the changed areas. + /// If true, then run a faster slightly less optimal diff. + /// [deadline] is the time when the diff should be complete by. + /// Returns a List of Diff objects. + List _diff_compute(String text1, String text2, bool checklines, DateTime deadline) { + List diffs = []; + + if (text1.isEmpty) { + // Just add some text (speedup). + diffs.add(Diff(Operation.insert, text2)); + return diffs; + } + + if (text2.isEmpty) { + // Just delete some text (speedup). + diffs.add(Diff(Operation.delete, text1)); + return diffs; + } + + String longtext = text1.length > text2.length ? text1 : text2; + String shorttext = text1.length > text2.length ? text2 : text1; + int i = longtext.indexOf(shorttext); + if (i != -1) { + // Shorter text is inside the longer text (speedup). + Operation op = (text1.length > text2.length) ? Operation.delete : Operation.insert; + diffs.add(Diff(op, longtext.substring(0, i))); + diffs.add(Diff(Operation.equal, shorttext)); + diffs.add(Diff(op, longtext.substring(i + shorttext.length))); + return diffs; + } + + if (shorttext.length == 1) { + // Single character string. + // After the previous speedup, the character can't be an equality. + diffs.add(Diff(Operation.delete, text1)); + diffs.add(Diff(Operation.insert, text2)); + return diffs; + } + + // Check to see if the problem can be split in two. + final hm = _diff_halfMatch(text1, text2); + if (hm != null) { + // A half-match was found, sort out the return data. + final text1_a = hm[0]; + final text1_b = hm[1]; + final text2_a = hm[2]; + final text2_b = hm[3]; + final mid_common = hm[4]; + // Send both pairs off for separate processing. + final diffs_a = diff_main(text1_a, text2_a, checklines, deadline); + final diffs_b = diff_main(text1_b, text2_b, checklines, deadline); + // Merge the results. + diffs = diffs_a; + diffs.add(Diff(Operation.equal, mid_common)); + diffs.addAll(diffs_b); + return diffs; + } + + if (checklines && text1.length > 100 && text2.length > 100) { + return _diff_lineMode(text1, text2, deadline); + } + + return _diff_bisect(text1, text2, deadline); + } + + /// Do a quick line-level diff on both strings, then rediff the parts for + /// greater accuracy. + /// This speedup can produce non-minimal diffs. + /// [text1] is the old string to be diffed. + /// [text2] is the new string to be diffed. + /// [deadline] is the time when the diff should be complete by. + /// Returns a List of Diff objects. + List _diff_lineMode(String text1, String text2, DateTime deadline) { + // Scan the text on a line-by-line basis first. + final a = _diff_linesToChars(text1, text2); + text1 = a['chars1']; + text2 = a['chars2']; + final linearray = a['lineArray']; + + final diffs = diff_main(text1, text2, false, deadline); + + // Convert the diff back to original text. + _diff_charsToLines(diffs, linearray); + // Eliminate freak matches (e.g. blank lines) + diff_cleanupSemantic(diffs); + + // Rediff any replacement blocks, this time character-by-character. + // Add a dummy entry at the end. + diffs.add(Diff(Operation.equal, '')); + int pointer = 0; + int count_delete = 0; + int count_insert = 0; + final text_delete = StringBuffer(); + final text_insert = StringBuffer(); + while (pointer < diffs.length) { + switch (diffs[pointer].operation) { + case Operation.insert: + count_insert++; + text_insert.write(diffs[pointer].text); + break; + case Operation.delete: + count_delete++; + text_delete.write(diffs[pointer].text); + break; + case Operation.equal: + // Upon reaching an equality, check for prior redundancies. + if (count_delete >= 1 && count_insert >= 1) { + // Delete the offending records and add the merged ones. + diffs.removeRange(pointer - count_delete - count_insert, pointer); + pointer = pointer - count_delete - count_insert; + final subDiff = diff_main(text_delete.toString(), text_insert.toString(), false, deadline); + for (int j = subDiff.length - 1; j >= 0; j--) { + diffs.insert(pointer, subDiff[j]); + } + pointer = pointer + subDiff.length; + } + count_insert = 0; + count_delete = 0; + text_delete.clear(); + text_insert.clear(); + break; + } + pointer++; + } + diffs.removeLast(); // Remove the dummy entry at the end. + + return diffs; + } + + /// Find the 'middle snake' of a diff, split the problem in two + /// and return the recursively constructed diff. + /// See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations. + /// [text1] is the old string to be diffed. + /// [text2] is the new string to be diffed. + /// [deadline] is the time at which to bail if not yet complete. + /// Returns a List of Diff objects. + List _diff_bisect(String text1, String text2, DateTime deadline) { + // Cache the text lengths to prevent multiple calls. + final text1_length = text1.length; + final text2_length = text2.length; + final max_d = (text1_length + text2_length + 1) ~/ 2; + final v_offset = max_d; + final v_length = 2 * max_d; + final v1 = List.filled(v_length, -1); + final v2 = List.filled(v_length, -1); + v1[v_offset + 1] = 0; + v2[v_offset + 1] = 0; + final delta = text1_length - text2_length; + // If the total number of characters is odd, then the front path will + // collide with the reverse path. + final front = (delta % 2 != 0); + // Offsets for start and end of k loop. + // Prevents mapping of space beyond the grid. + int k1start = 0; + int k1end = 0; + int k2start = 0; + int k2end = 0; + for (int d = 0; d < max_d; d++) { + // Bail out if deadline is reached. + if ((DateTime.now()).compareTo(deadline) == 1) { + break; + } + + // Walk the front path one step. + for (int k1 = -d + k1start; k1 <= d - k1end; k1 += 2) { + int k1_offset = v_offset + k1; + int x1; + if (k1 == -d || k1 != d && v1[k1_offset - 1] < v1[k1_offset + 1]) { + x1 = v1[k1_offset + 1]; + } else { + x1 = v1[k1_offset - 1] + 1; + } + int y1 = x1 - k1; + while (x1 < text1_length && y1 < text2_length && text1[x1] == text2[y1]) { + x1++; + y1++; + } + v1[k1_offset] = x1; + if (x1 > text1_length) { + // Ran off the right of the graph. + k1end += 2; + } else if (y1 > text2_length) { + // Ran off the bottom of the graph. + k1start += 2; + } else if (front) { + int k2_offset = v_offset + delta - k1; + if (k2_offset >= 0 && k2_offset < v_length && v2[k2_offset] != -1) { + // Mirror x2 onto top-left coordinate system. + int x2 = text1_length - v2[k2_offset]; + if (x1 >= x2) { + // Overlap detected. + return _diff_bisectSplit(text1, text2, x1, y1, deadline); + } + } + } + } + + // Walk the reverse path one step. + for (int k2 = -d + k2start; k2 <= d - k2end; k2 += 2) { + int k2_offset = v_offset + k2; + int x2; + if (k2 == -d || k2 != d && v2[k2_offset - 1] < v2[k2_offset + 1]) { + x2 = v2[k2_offset + 1]; + } else { + x2 = v2[k2_offset - 1] + 1; + } + int y2 = x2 - k2; + while (x2 < text1_length && y2 < text2_length && text1[text1_length - x2 - 1] == text2[text2_length - y2 - 1]) { + x2++; + y2++; + } + v2[k2_offset] = x2; + if (x2 > text1_length) { + // Ran off the left of the graph. + k2end += 2; + } else if (y2 > text2_length) { + // Ran off the top of the graph. + k2start += 2; + } else if (!front) { + int k1_offset = v_offset + delta - k2; + if (k1_offset >= 0 && k1_offset < v_length && v1[k1_offset] != -1) { + int x1 = v1[k1_offset]; + int y1 = v_offset + x1 - k1_offset; + // Mirror x2 onto top-left coordinate system. + x2 = text1_length - x2; + if (x1 >= x2) { + // Overlap detected. + return _diff_bisectSplit(text1, text2, x1, y1, deadline); + } + } + } + } + } + // Diff took too long and hit the deadline or + // number of diffs equals number of characters, no commonality at all. + return [Diff(Operation.delete, text1), Diff(Operation.insert, text2)]; + } + + /// Hack to allow unit tests to call private method. Do not use. + List test_diff_bisect(String text1, String text2, DateTime deadline) { + return _diff_bisect(text1, text2, deadline); + } + + /// Given the location of the 'middle snake', split the diff in two parts + /// and recurse. + /// [text1] is the old string to be diffed. + /// [text2] is the new string to be diffed. + /// [x] is the index of split point in text1. + /// [y] is the index of split point in text2. + /// [deadline] is the time at which to bail if not yet complete. + /// Returns a List of Diff objects. + List _diff_bisectSplit(String text1, String text2, int x, int y, DateTime deadline) { + final text1a = text1.substring(0, x); + final text2a = text2.substring(0, y); + final text1b = text1.substring(x); + final text2b = text2.substring(y); + + // Compute both diffs serially. + final diffs = diff_main(text1a, text2a, false, deadline); + final diffsb = diff_main(text1b, text2b, false, deadline); + + diffs.addAll(diffsb); + return diffs; + } + + /// Split two texts into a list of strings. Reduce the texts to a string of + /// hashes where each Unicode character represents one line. + /// [text1] is the first string. + /// [text2] is the second string. + /// Returns a Map containing the encoded text1, the encoded text2 and + /// the List of unique strings. The zeroth element of the List of + /// unique strings is intentionally blank. + Map _diff_linesToChars(String text1, String text2) { + final lineArray = []; + final lineHash = HashMap(); + // e.g. linearray[4] == 'Hello\n' + // e.g. linehash['Hello\n'] == 4 + + // '\x00' is a valid character, but various debuggers don't like it. + // So we'll insert a junk entry to avoid generating a null character. + lineArray.add(''); + + // Allocate 2/3rds of the space for text1, the rest for text2. + String chars1 = _diff_linesToCharsMunge(text1, lineArray, lineHash, 40000); + String chars2 = _diff_linesToCharsMunge(text2, lineArray, lineHash, 65535); + return {'chars1': chars1, 'chars2': chars2, 'lineArray': lineArray}; + } + + /// Hack to allow unit tests to call private method. Do not use. + Map test_diff_linesToChars(String text1, String text2) { + return _diff_linesToChars(text1, text2); + } + + /// Split a text into a list of strings. Reduce the texts to a string of + /// hashes where each Unicode character represents one line. + /// [text] is the string to encode. + /// [lineArray] is a List of unique strings. + /// [lineHash] is a Map of strings to indices. + /// [maxLines] is the maximum length for lineArray. + /// Returns an encoded string. + String _diff_linesToCharsMunge(String text, List lineArray, Map lineHash, int maxLines) { + int lineStart = 0; + int lineEnd = -1; + String line; + final chars = StringBuffer(); + // Walk the text, pulling out a substring for each line. + // text.split('\n') would would temporarily double our memory footprint. + // Modifying text would create many large strings to garbage collect. + while (lineEnd < text.length - 1) { + lineEnd = text.indexOf('\n', lineStart); + if (lineEnd == -1) { + lineEnd = text.length - 1; + } + line = text.substring(lineStart, lineEnd + 1); + + if (lineHash.containsKey(line)) { + chars.writeCharCode(lineHash[line]!); + } else { + if (lineArray.length == maxLines) { + // Bail out at 65535 because + // final chars1 = StringBuffer(); + // chars1.writeCharCode(65536); + // chars1.toString().codeUnitAt(0) == 55296; + line = text.substring(lineStart); + lineEnd = text.length; + } + lineArray.add(line); + lineHash[line] = lineArray.length - 1; + chars.writeCharCode(lineArray.length - 1); + } + lineStart = lineEnd + 1; + } + return chars.toString(); + } + + /// Rehydrate the text in a diff from a string of line hashes to real lines of + /// text. + /// [diffs] is a List of Diff objects. + /// [lineArray] is a List of unique strings. + void _diff_charsToLines(List diffs, List lineArray) { + final text = StringBuffer(); + for (Diff diff in diffs) { + for (int j = 0; j < diff.text.length; j++) { + text.write(lineArray[diff.text.codeUnitAt(j)]); + } + diff.text = text.toString(); + text.clear(); + } + } + + /// Hack to allow unit tests to call private method. Do not use. + void test_diff_charsToLines(List diffs, List lineArray) { + _diff_charsToLines(diffs, lineArray); + } + + /// Determine the common prefix of two strings + /// [text1] is the first string. + /// [text2] is the second string. + /// Returns the number of characters common to the start of each string. + int diff_commonPrefix(String text1, String text2) { + // TODO: Once Dart's performance stabilizes, determine if linear or binary + // search is better. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + final n = min(text1.length, text2.length); + for (int i = 0; i < n; i++) { + if (text1[i] != text2[i]) { + return i; + } + } + return n; + } + + /// Determine the common suffix of two strings + /// [text1] is the first string. + /// [text2] is the second string. + /// Returns the number of characters common to the end of each string. + int diff_commonSuffix(String text1, String text2) { + // TODO: Once Dart's performance stabilizes, determine if linear or binary + // search is better. + // Performance analysis: https://neil.fraser.name/news/2007/10/09/ + final text1_length = text1.length; + final text2_length = text2.length; + final n = min(text1_length, text2_length); + for (int i = 1; i <= n; i++) { + if (text1[text1_length - i] != text2[text2_length - i]) { + return i - 1; + } + } + return n; + } + + /// Determine if the suffix of one string is the prefix of another. + /// [text1] is the first string. + /// [text2] is the second string. + /// Returns the number of characters common to the end of the first + /// string and the start of the second string. + int _diff_commonOverlap(String text1, String text2) { + // Eliminate the null case. + if (text1.isEmpty || text2.isEmpty) { + return 0; + } + // Cache the text lengths to prevent multiple calls. + final text1_length = text1.length; + final text2_length = text2.length; + // Truncate the longer string. + if (text1_length > text2_length) { + text1 = text1.substring(text1_length - text2_length); + } else if (text1_length < text2_length) { + text2 = text2.substring(0, text1_length); + } + final text_length = min(text1_length, text2_length); + // Quick check for the worst case. + if (text1 == text2) { + return text_length; + } + + // Start by looking for a single character match + // and increase length until no match is found. + // Performance analysis: https://neil.fraser.name/news/2010/11/04/ + int best = 0; + int length = 1; + while (true) { + String pattern = text1.substring(text_length - length); + int found = text2.indexOf(pattern); + if (found == -1) { + return best; + } + length += found; + if (found == 0 || text1.substring(text_length - length) == text2.substring(0, length)) { + best = length; + length++; + } + } + } + + /// Hack to allow unit tests to call private method. Do not use. + int test_diff_commonOverlap(String text1, String text2) { + return _diff_commonOverlap(text1, text2); + } + + /// Do the two texts share a substring which is at least half the length of + /// the longer text? + /// This speedup can produce non-minimal diffs. + /// [text1] is the first string. + /// [text2] is the second string. + /// Returns a five element List of Strings, containing the prefix of text1, + /// the suffix of text1, the prefix of text2, the suffix of text2 and the + /// common middle. Or null if there was no match. + List? _diff_halfMatch(String text1, String text2) { + if (Diff_Timeout <= 0) { + // Don't risk returning a non-optimal diff if we have unlimited time. + return null; + } + final longtext = text1.length > text2.length ? text1 : text2; + final shorttext = text1.length > text2.length ? text2 : text1; + if (longtext.length < 4 || shorttext.length * 2 < longtext.length) { + return null; // Pointless. + } + + // First check if the second quarter is the seed for a half-match. + final hm1 = _diff_halfMatchI(longtext, shorttext, ((longtext.length + 3) / 4).ceil().toInt()); + // Check again based on the third quarter. + final hm2 = _diff_halfMatchI(longtext, shorttext, ((longtext.length + 1) / 2).ceil().toInt()); + List hm; + if (hm1 == null && hm2 == null) { + return null; + } else if (hm2 == null) { + hm = hm1!; + } else if (hm1 == null) { + hm = hm2; + } else { + // Both matched. Select the longest. + hm = hm1[4].length > hm2[4].length ? hm1 : hm2; + } + + // A half-match was found, sort out the return data. + if (text1.length > text2.length) { + return hm; + //return [hm[0], hm[1], hm[2], hm[3], hm[4]]; + } else { + return [hm[2], hm[3], hm[0], hm[1], hm[4]]; + } + } + + /// Hack to allow unit tests to call private method. Do not use. + List? test_diff_halfMatch(String text1, String text2) { + return _diff_halfMatch(text1, text2); + } + + /// Does a substring of shorttext exist within longtext such that the + /// substring is at least half the length of longtext? + /// [longtext] is the longer string. + /// [shorttext is the shorter string. + /// [i] Start index of quarter length substring within longtext. + /// Returns a five element String array, containing the prefix of longtext, + /// the suffix of longtext, the prefix of shorttext, the suffix of + /// shorttext and the common middle. Or null if there was no match. + List? _diff_halfMatchI(String longtext, String shorttext, int i) { + // Start with a 1/4 length substring at position i as a seed. + final seed = longtext.substring(i, i + (longtext.length / 4).floor().toInt()); + int j = -1; + String best_common = ''; + String best_longtext_a = '', best_longtext_b = ''; + String best_shorttext_a = '', best_shorttext_b = ''; + while ((j = shorttext.indexOf(seed, j + 1)) != -1) { + int prefixLength = diff_commonPrefix(longtext.substring(i), shorttext.substring(j)); + int suffixLength = diff_commonSuffix(longtext.substring(0, i), shorttext.substring(0, j)); + if (best_common.length < suffixLength + prefixLength) { + best_common = shorttext.substring(j - suffixLength, j) + shorttext.substring(j, j + prefixLength); + best_longtext_a = longtext.substring(0, i - suffixLength); + best_longtext_b = longtext.substring(i + prefixLength); + best_shorttext_a = shorttext.substring(0, j - suffixLength); + best_shorttext_b = shorttext.substring(j + prefixLength); + } + } + if (best_common.length * 2 >= longtext.length) { + return [best_longtext_a, best_longtext_b, best_shorttext_a, best_shorttext_b, best_common]; + } else { + return null; + } + } + + /// Reduce the number of edits by eliminating semantically trivial equalities. + /// [diffs] is a List of Diff objects. + void diff_cleanupSemantic(List diffs) { + bool changes = false; + // Stack of indices where equalities are found. + final equalities = []; + // Always equal to diffs[equalities.last()].text + String? lastEquality; + int pointer = 0; // Index of current position. + // Number of characters that changed prior to the equality. + int length_insertions1 = 0; + int length_deletions1 = 0; + // Number of characters that changed after the equality. + int length_insertions2 = 0; + int length_deletions2 = 0; + while (pointer < diffs.length) { + if (diffs[pointer].operation == Operation.equal) { + // Equality found. + equalities.add(pointer); + length_insertions1 = length_insertions2; + length_deletions1 = length_deletions2; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = diffs[pointer].text; + } else { + // An insertion or deletion. + if (diffs[pointer].operation == Operation.insert) { + length_insertions2 += diffs[pointer].text.length; + } else { + length_deletions2 += diffs[pointer].text.length; + } + // Eliminate an equality that is smaller or equal to the edits on both + // sides of it. + if (lastEquality != null && (lastEquality.length <= max(length_insertions1, length_deletions1)) && (lastEquality.length <= max(length_insertions2, length_deletions2))) { + // Duplicate record. + diffs.insert(equalities.last, Diff(Operation.delete, lastEquality)); + // Change second copy to insert. + diffs[equalities.last + 1].operation = Operation.insert; + // Throw away the equality we just deleted. + equalities.removeLast(); + // Throw away the previous equality (it needs to be reevaluated). + if (equalities.isNotEmpty) { + equalities.removeLast(); + } + pointer = equalities.isEmpty ? -1 : equalities.last; + length_insertions1 = 0; // Reset the counters. + length_deletions1 = 0; + length_insertions2 = 0; + length_deletions2 = 0; + lastEquality = null; + changes = true; + } + } + pointer++; + } + + // Normalize the diff. + if (changes) { + diff_cleanupMerge(diffs); + } + _diff_cleanupSemanticLossless(diffs); + + // Find any overlaps between deletions and insertions. + // e.g: abcxxxxxxdef + // -> abcxxxdef + // e.g: xxxabcdefxxx + // -> defxxxabc + // Only extract an overlap if it is as big as the edit ahead or behind it. + pointer = 1; + while (pointer < diffs.length) { + if (diffs[pointer - 1].operation == Operation.delete && diffs[pointer].operation == Operation.insert) { + String deletion = diffs[pointer - 1].text; + String insertion = diffs[pointer].text; + int overlap_length1 = _diff_commonOverlap(deletion, insertion); + int overlap_length2 = _diff_commonOverlap(insertion, deletion); + if (overlap_length1 >= overlap_length2) { + if (overlap_length1 >= deletion.length / 2 || overlap_length1 >= insertion.length / 2) { + // Overlap found. + // Insert an equality and trim the surrounding edits. + diffs.insert(pointer, Diff(Operation.equal, insertion.substring(0, overlap_length1))); + diffs[pointer - 1].text = deletion.substring(0, deletion.length - overlap_length1); + diffs[pointer + 1].text = insertion.substring(overlap_length1); + pointer++; + } + } else { + if (overlap_length2 >= deletion.length / 2 || overlap_length2 >= insertion.length / 2) { + // Reverse overlap found. + // Insert an equality and swap and trim the surrounding edits. + diffs.insert(pointer, Diff(Operation.equal, deletion.substring(0, overlap_length2))); + diffs[pointer - 1] = Diff(Operation.insert, insertion.substring(0, insertion.length - overlap_length2)); + diffs[pointer + 1] = Diff(Operation.delete, deletion.substring(overlap_length2)); + pointer++; + } + } + pointer++; + } + pointer++; + } + } + + /// Look for single edits surrounded on both sides by equalities + /// which can be shifted sideways to align the edit to a word boundary. + /// e.g: The cat came. -> The cat came. + /// [diffs] is a List of Diff objects. + void _diff_cleanupSemanticLossless(List diffs) { + /// Given two strings, compute a score representing whether the internal + /// boundary falls on logical boundaries. + /// Scores range from 6 (best) to 0 (worst). + /// Closure, but does not reference any external variables. + /// [one] the first string. + /// [two] the second string. + /// Returns the score. + int _diff_cleanupSemanticScore(String one, String two) { + if (one.isEmpty || two.isEmpty) { + // Edges are the best. + return 6; + } + + // Each port of this function behaves slightly differently due to + // subtle differences in each language's definition of things like + // 'whitespace'. Since this function's purpose is largely cosmetic, + // the choice has been made to use each language's native features + // rather than force total conformity. + String char1 = one[one.length - 1]; + String char2 = two[0]; + bool nonAlphaNumeric1 = char1.contains(nonAlphaNumericRegex_); + bool nonAlphaNumeric2 = char2.contains(nonAlphaNumericRegex_); + bool whitespace1 = nonAlphaNumeric1 && char1.contains(whitespaceRegex_); + bool whitespace2 = nonAlphaNumeric2 && char2.contains(whitespaceRegex_); + bool lineBreak1 = whitespace1 && char1.contains(linebreakRegex_); + bool lineBreak2 = whitespace2 && char2.contains(linebreakRegex_); + bool blankLine1 = lineBreak1 && one.contains(blanklineEndRegex_); + bool blankLine2 = lineBreak2 && two.contains(blanklineStartRegex_); + + if (blankLine1 || blankLine2) { + // Five points for blank lines. + return 5; + } else if (lineBreak1 || lineBreak2) { + // Four points for line breaks. + return 4; + } else if (nonAlphaNumeric1 && !whitespace1 && whitespace2) { + // Three points for end of sentences. + return 3; + } else if (whitespace1 || whitespace2) { + // Two points for whitespace. + return 2; + } else if (nonAlphaNumeric1 || nonAlphaNumeric2) { + // One point for non-alphanumeric. + return 1; + } + return 0; + } + + int pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if (diffs[pointer - 1].operation == Operation.equal && diffs[pointer + 1].operation == Operation.equal) { + // This is a single edit surrounded by equalities. + String equality1 = diffs[pointer - 1].text; + String edit = diffs[pointer].text; + String equality2 = diffs[pointer + 1].text; + + // First, shift the edit as far left as possible. + int commonOffset = diff_commonSuffix(equality1, edit); + if (commonOffset != 0) { + String commonString = edit.substring(edit.length - commonOffset); + equality1 = equality1.substring(0, equality1.length - commonOffset); + edit = commonString + edit.substring(0, edit.length - commonOffset); + equality2 = commonString + equality2; + } + + // Second, step character by character right, looking for the best fit. + String bestEquality1 = equality1; + String bestEdit = edit; + String bestEquality2 = equality2; + int bestScore = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2); + while (edit.isNotEmpty && equality2.isNotEmpty && edit[0] == equality2[0]) { + equality1 = equality1 + edit[0]; + edit = edit.substring(1) + equality2[0]; + equality2 = equality2.substring(1); + int score = _diff_cleanupSemanticScore(equality1, edit) + _diff_cleanupSemanticScore(edit, equality2); + // The >= encourages trailing rather than leading whitespace on edits. + if (score >= bestScore) { + bestScore = score; + bestEquality1 = equality1; + bestEdit = edit; + bestEquality2 = equality2; + } + } + + if (diffs[pointer - 1].text != bestEquality1) { + // We have an improvement, save it back to the diff. + if (bestEquality1.isNotEmpty) { + diffs[pointer - 1].text = bestEquality1; + } else { + diffs.removeAt(pointer - 1); + pointer--; + } + diffs[pointer].text = bestEdit; + if (bestEquality2.isNotEmpty) { + diffs[pointer + 1].text = bestEquality2; + } else { + diffs.removeAt(pointer + 1); + pointer--; + } + } + } + pointer++; + } + } + + /// Hack to allow unit tests to call private method. Do not use. + void test_diff_cleanupSemanticLossless(List diffs) { + _diff_cleanupSemanticLossless(diffs); + } + + // Define some regex patterns for matching boundaries. + RegExp nonAlphaNumericRegex_ = RegExp(r'[^a-zA-Z0-9]'); + RegExp whitespaceRegex_ = RegExp(r'\s'); + RegExp linebreakRegex_ = RegExp(r'[\r\n]'); + RegExp blanklineEndRegex_ = RegExp(r'\n\r?\n$'); + RegExp blanklineStartRegex_ = RegExp(r'^\r?\n\r?\n'); + + /// Reduce the number of edits by eliminating operationally trivial equalities. + /// [diffs] is a List of Diff objects. + void diff_cleanupEfficiency(List diffs) { + bool changes = false; + // Stack of indices where equalities are found. + final equalities = []; + // Always equal to diffs[equalities.last()].text + String? lastEquality; + int pointer = 0; // Index of current position. + // Is there an insertion operation before the last equality. + bool pre_ins = false; + // Is there a deletion operation before the last equality. + bool pre_del = false; + // Is there an insertion operation after the last equality. + bool post_ins = false; + // Is there a deletion operation after the last equality. + bool post_del = false; + while (pointer < diffs.length) { + if (diffs[pointer].operation == Operation.equal) { + // Equality found. + if (diffs[pointer].text.length < Diff_EditCost && (post_ins || post_del)) { + // Candidate found. + equalities.add(pointer); + pre_ins = post_ins; + pre_del = post_del; + lastEquality = diffs[pointer].text; + } else { + // Not a candidate, and can never become one. + equalities.clear(); + lastEquality = null; + } + post_ins = post_del = false; + } else { + // An insertion or deletion. + if (diffs[pointer].operation == Operation.delete) { + post_del = true; + } else { + post_ins = true; + } + /* + * Five types to be split: + * ABXYCD + * AXCD + * ABXC + * AXCD + * ABXC + */ + if (lastEquality != null && ((pre_ins && pre_del && post_ins && post_del) || ((lastEquality.length < Diff_EditCost / 2) && ((pre_ins ? 1 : 0) + (pre_del ? 1 : 0) + (post_ins ? 1 : 0) + (post_del ? 1 : 0)) == 3))) { + // Duplicate record. + diffs.insert(equalities.last, Diff(Operation.delete, lastEquality)); + // Change second copy to insert. + diffs[equalities.last + 1].operation = Operation.insert; + equalities.removeLast(); // Throw away the equality we just deleted. + lastEquality = null; + if (pre_ins && pre_del) { + // No changes made which could affect previous entry, keep going. + post_ins = post_del = true; + equalities.clear(); + } else { + if (equalities.isNotEmpty) { + equalities.removeLast(); + } + pointer = equalities.isEmpty ? -1 : equalities.last; + post_ins = post_del = false; + } + changes = true; + } + } + pointer++; + } + + if (changes) { + diff_cleanupMerge(diffs); + } + } + + /// Reorder and merge like edit sections. Merge equalities. + /// Any edit section can move as long as it doesn't cross an equality. + /// [diffs] is a List of Diff objects. + void diff_cleanupMerge(List diffs) { + diffs.add(Diff(Operation.equal, '')); // Add a dummy entry at the end. + int pointer = 0; + int count_delete = 0; + int count_insert = 0; + String text_delete = ''; + String text_insert = ''; + int commonlength; + while (pointer < diffs.length) { + switch (diffs[pointer].operation) { + case Operation.insert: + count_insert++; + text_insert += diffs[pointer].text; + pointer++; + break; + case Operation.delete: + count_delete++; + text_delete += diffs[pointer].text; + pointer++; + break; + case Operation.equal: + // Upon reaching an equality, check for prior redundancies. + if (count_delete + count_insert > 1) { + if (count_delete != 0 && count_insert != 0) { + // Factor out any common prefixies. + commonlength = diff_commonPrefix(text_insert, text_delete); + if (commonlength != 0) { + if ((pointer - count_delete - count_insert) > 0 && diffs[pointer - count_delete - count_insert - 1].operation == Operation.equal) { + final i = pointer - count_delete - count_insert - 1; + diffs[i].text = diffs[i].text + text_insert.substring(0, commonlength); + } else { + diffs.insert(0, Diff(Operation.equal, text_insert.substring(0, commonlength))); + pointer++; + } + text_insert = text_insert.substring(commonlength); + text_delete = text_delete.substring(commonlength); + } + + // Factor out any common suffixies. + commonlength = diff_commonSuffix(text_insert, text_delete); + if (commonlength != 0) { + diffs[pointer].text = text_insert.substring(text_insert.length - commonlength) + diffs[pointer].text; + text_insert = text_insert.substring(0, text_insert.length - commonlength); + text_delete = text_delete.substring(0, text_delete.length - commonlength); + } + } + // Delete the offending records and add the merged ones. + pointer -= count_delete + count_insert; + diffs.removeRange(pointer, pointer + count_delete + count_insert); + if (text_delete.isNotEmpty) { + diffs.insert(pointer, Diff(Operation.delete, text_delete)); + pointer++; + } + if (text_insert.isNotEmpty) { + diffs.insert(pointer, Diff(Operation.insert, text_insert)); + pointer++; + } + pointer++; + } else if (pointer != 0 && diffs[pointer - 1].operation == Operation.equal) { + // Merge this equality with the previous one. + diffs[pointer - 1].text = diffs[pointer - 1].text + diffs[pointer].text; + diffs.removeAt(pointer); + } else { + pointer++; + } + count_insert = 0; + count_delete = 0; + text_delete = ''; + text_insert = ''; + break; + } + } + if (diffs.last.text.isEmpty) { + diffs.removeLast(); // Remove the dummy entry at the end. + } + + // Second pass: look for single edits surrounded on both sides by equalities + // which can be shifted sideways to eliminate an equality. + // e.g: ABAC -> ABAC + bool changes = false; + pointer = 1; + // Intentionally ignore the first and last element (don't need checking). + while (pointer < diffs.length - 1) { + if (diffs[pointer - 1].operation == Operation.equal && diffs[pointer + 1].operation == Operation.equal) { + // This is a single edit surrounded by equalities. + if (diffs[pointer].text.endsWith(diffs[pointer - 1].text)) { + // Shift the edit over the previous equality. + diffs[pointer].text = diffs[pointer - 1].text + diffs[pointer].text.substring(0, diffs[pointer].text.length - diffs[pointer - 1].text.length); + diffs[pointer + 1].text = diffs[pointer - 1].text + diffs[pointer + 1].text; + diffs.removeAt(pointer - 1); + changes = true; + } else if (diffs[pointer].text.startsWith(diffs[pointer + 1].text)) { + // Shift the edit over the next equality. + diffs[pointer - 1].text = diffs[pointer - 1].text + diffs[pointer + 1].text; + diffs[pointer].text = diffs[pointer].text.substring(diffs[pointer + 1].text.length) + diffs[pointer + 1].text; + diffs.removeAt(pointer + 1); + changes = true; + } + } + pointer++; + } + // If shifts were made, the diff needs reordering and another shift sweep. + if (changes) { + diff_cleanupMerge(diffs); + } + } + + /// loc is a location in text1, compute and return the equivalent location in + /// text2. + /// e.g. "The cat" vs "The big cat", 1->1, 5->8 + /// [diffs] is a List of Diff objects. + /// [loc] is the location within text1. + /// Returns the location within text2. + int diff_xIndex(List diffs, int loc) { + int chars1 = 0; + int chars2 = 0; + int last_chars1 = 0; + int last_chars2 = 0; + Diff? lastDiff; + for (Diff aDiff in diffs) { + if (aDiff.operation != Operation.insert) { + // Equality or deletion. + chars1 += aDiff.text.length; + } + if (aDiff.operation != Operation.delete) { + // Equality or insertion. + chars2 += aDiff.text.length; + } + if (chars1 > loc) { + // Overshot the location. + lastDiff = aDiff; + break; + } + last_chars1 = chars1; + last_chars2 = chars2; + } + if (lastDiff != null && lastDiff.operation == Operation.delete) { + // The location was deleted. + return last_chars2; + } + // Add the remaining character length. + return last_chars2 + (loc - last_chars1); + } + + /// Convert a Diff list into a pretty HTML report. + /// [diffs] is a List of Diff objects. + /// Returns an HTML representation. + String diff_prettyHtml(List diffs) { + final html = StringBuffer(); + for (Diff aDiff in diffs) { + String text = aDiff.text.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('\n', '¶
'); + switch (aDiff.operation) { + case Operation.insert: + html.write(''); + html.write(text); + html.write(''); + break; + case Operation.delete: + html.write(''); + html.write(text); + html.write(''); + break; + case Operation.equal: + html.write(''); + html.write(text); + html.write(''); + break; + } + } + return html.toString(); + } + + /// Compute and return the source text (all equalities and deletions). + /// [diffs] is a List of Diff objects. + /// Returns the source text. + String diff_text1(List diffs) { + final text = StringBuffer(); + for (Diff aDiff in diffs) { + if (aDiff.operation != Operation.insert) { + text.write(aDiff.text); + } + } + return text.toString(); + } + + /// Compute and return the destination text (all equalities and insertions). + /// [diffs] is a List of Diff objects. + /// Returns the destination text. + String diff_text2(List diffs) { + final text = StringBuffer(); + for (Diff aDiff in diffs) { + if (aDiff.operation != Operation.delete) { + text.write(aDiff.text); + } + } + return text.toString(); + } + + /// Compute the Levenshtein distance; the number of inserted, deleted or + /// substituted characters. + /// [diffs] is a List of Diff objects. + /// Returns the number of changes. + int diff_levenshtein(List diffs) { + int levenshtein = 0; + int insertions = 0; + int deletions = 0; + for (Diff aDiff in diffs) { + switch (aDiff.operation) { + case Operation.insert: + insertions += aDiff.text.length; + break; + case Operation.delete: + deletions += aDiff.text.length; + break; + case Operation.equal: + // A deletion and an insertion is one substitution. + levenshtein += max(insertions, deletions); + insertions = 0; + deletions = 0; + break; + } + } + levenshtein += max(insertions, deletions); + return levenshtein; + } + + /// Crush the diff into an encoded string which describes the operations + /// required to transform text1 into text2. + /// E.g. =3\t-2\t+ing -> Keep 3 chars, delete 2 chars, insert 'ing'. + /// Operations are tab-separated. Inserted text is escaped using %xx notation. + /// [diffs] is a List of Diff objects. + /// Returns the delta text. + String diff_toDelta(List diffs) { + final text = StringBuffer(); + for (Diff aDiff in diffs) { + switch (aDiff.operation) { + case Operation.insert: + text.write('+'); + text.write(Uri.encodeFull(aDiff.text)); + text.write('\t'); + break; + case Operation.delete: + text.write('-'); + text.write(aDiff.text.length); + text.write('\t'); + break; + case Operation.equal: + text.write('='); + text.write(aDiff.text.length); + text.write('\t'); + break; + } + } + String delta = text.toString(); + if (delta.isNotEmpty) { + // Strip off trailing tab character. + delta = delta.substring(0, delta.length - 1); + } + return delta.replaceAll('%20', ' '); + } + + /// Given the original text1, and an encoded string which describes the + /// operations required to transform text1 into text2, compute the full diff. + /// [text1] is the source string for the diff. + /// [delta] is the delta text. + /// Returns a List of Diff objects or null if invalid. + /// Throws ArgumentError if invalid input. + List diff_fromDelta(String text1, String delta) { + final diffs = []; + int pointer = 0; // Cursor in text1 + final tokens = delta.split('\t'); + for (String token in tokens) { + if (token.isEmpty) { + // Blank tokens are ok (from a trailing \t). + continue; + } + // Each token begins with a one character parameter which specifies the + // operation of this token (delete, insert, equality). + String param = token.substring(1); + switch (token[0]) { + case '+': + // decode would change all "+" to " " + param = param.replaceAll('+', '%2B'); + try { + param = Uri.decodeFull(param); + } on ArgumentError { + // Malformed URI sequence. + throw ArgumentError('Illegal escape in diff_fromDelta: $param'); + } + diffs.add(Diff(Operation.insert, param)); + break; + case '-': + // Fall through. + case '=': + int n; + try { + n = int.parse(param); + } on FormatException { + throw ArgumentError('Invalid number in diff_fromDelta: $param'); + } + if (n < 0) { + throw ArgumentError('Negative number in diff_fromDelta: $param'); + } + String text; + try { + text = text1.substring(pointer, pointer += n); + } on RangeError { + throw ArgumentError('Delta length ($pointer)' + ' larger than source text length (${text1.length}).'); + } + if (token[0] == '=') { + diffs.add(Diff(Operation.equal, text)); + } else { + diffs.add(Diff(Operation.delete, text)); + } + break; + default: + // Anything else is an error. + throw ArgumentError('Invalid diff operation in diff_fromDelta: ${token[0]}'); + } + } + if (pointer != text1.length) { + throw ArgumentError('Delta length ($pointer)' + ' smaller than source text length (${text1.length}).'); + } + return diffs; + } + + // MATCH FUNCTIONS + + /// Locate the best instance of 'pattern' in 'text' near 'loc'. + /// Returns -1 if no match found. + /// [text] is the text to search. + /// [pattern] is the pattern to search for. + /// [loc] is the location to search around. + /// Returns the best match index or -1. + int match_main(String? text, String? pattern, int loc) { + // Check for null inputs. + if (text == null || pattern == null) { + throw ArgumentError('Null inputs. (match_main)'); + } + + loc = max(0, min(loc, text.length)); + if (text == pattern) { + // Shortcut (potentially not guaranteed by the algorithm) + return 0; + } else if (text.isEmpty) { + // Nothing to match. + return -1; + } else if (loc + pattern.length <= text.length && text.substring(loc, loc + pattern.length) == pattern) { + // Perfect match at the perfect spot! (Includes case of null pattern) + return loc; + } else { + // Do a fuzzy compare. + return _match_bitap(text, pattern, loc); + } + } + + /// Locate the best instance of 'pattern' in 'text' near 'loc' using the + /// Bitap algorithm. Returns -1 if no match found. + /// [text] is the the text to search. + /// [pattern] is the pattern to search for. + /// [loc] is the location to search around. + /// Returns the best match index or -1. + int _match_bitap(String text, String pattern, int loc) { + if (Match_MaxBits != 0 && pattern.length > Match_MaxBits) { + throw Exception('Pattern too long for this application.'); + } + // Initialise the alphabet. + Map s = _match_alphabet(pattern); + + // Highest score beyond which we give up. + double score_threshold = Match_Threshold; + // Is there a nearby exact match? (speedup) + int best_loc = text.indexOf(pattern, loc); + if (best_loc != -1) { + score_threshold = min(_match_bitapScore(0, best_loc, loc, pattern), score_threshold); + // What about in the other direction? (speedup) + best_loc = text.lastIndexOf(pattern, loc + pattern.length); + if (best_loc != -1) { + score_threshold = min(_match_bitapScore(0, best_loc, loc, pattern), score_threshold); + } + } + + // Initialise the bit arrays. + final matchmask = 1 << (pattern.length - 1); + best_loc = -1; + + int bin_min, bin_mid; + int bin_max = pattern.length + text.length; + late List last_rd; + for (int d = 0; d < pattern.length; d++) { + // Scan for the best match; each iteration allows for one more error. + // Run a binary search to determine how far from 'loc' we can stray at + // this error level. + bin_min = 0; + bin_mid = bin_max; + while (bin_min < bin_mid) { + if (_match_bitapScore(d, loc + bin_mid, loc, pattern) <= score_threshold) { + bin_min = bin_mid; + } else { + bin_max = bin_mid; + } + bin_mid = ((bin_max - bin_min) / 2 + bin_min).toInt(); + } + // Use the result from this iteration as the maximum for the next. + bin_max = bin_mid; + int start = max(1, loc - bin_mid + 1); + int finish = min(loc + bin_mid, text.length) + pattern.length; + + final rd = List.filled(finish + 2, -1); + rd[finish + 1] = (1 << d) - 1; + for (int j = finish; j >= start; j--) { + int charMatch; + if (text.length <= j - 1 || !s.containsKey(text[j - 1])) { + // Out of range. + charMatch = 0; + } else { + charMatch = s[text[j - 1]]!; + } + if (d == 0) { + // First pass: exact match. + rd[j] = ((rd[j + 1] << 1) | 1) & charMatch; + } else { + // Subsequent passes: fuzzy match. + rd[j] = ((rd[j + 1] << 1) | 1) & charMatch | (((last_rd[j + 1] | last_rd[j]) << 1) | 1) | last_rd[j + 1]; + } + if ((rd[j] & matchmask) != 0) { + double score = _match_bitapScore(d, j - 1, loc, pattern); + // This match will almost certainly be better than any existing + // match. But check anyway. + if (score <= score_threshold) { + // Told you so. + score_threshold = score; + best_loc = j - 1; + if (best_loc > loc) { + // When passing loc, don't exceed our current distance from loc. + start = max(1, 2 * loc - best_loc); + } else { + // Already passed loc, downhill from here on in. + break; + } + } + } + } + if (_match_bitapScore(d + 1, loc, loc, pattern) > score_threshold) { + // No hope for a (better) match at greater error levels. + break; + } + last_rd = rd; + } + return best_loc; + } + + /// Hack to allow unit tests to call private method. Do not use. + int test_match_bitap(String text, String pattern, int loc) { + return _match_bitap(text, pattern, loc); + } + + /// Compute and return the score for a match with e errors and x location. + /// [e] is the number of errors in match. + /// [x] is the location of match. + /// [loc] is the expected location of match. + /// [pattern] is the pattern being sought. + /// Returns the overall score for match (0.0 = good, 1.0 = bad). + double _match_bitapScore(int e, int x, int loc, String pattern) { + final accuracy = e / pattern.length; + final proximity = (loc - x).abs(); + if (Match_Distance == 0) { + // Dodge divide by zero error. + return proximity == 0 ? accuracy : 1.0; + } + return accuracy + proximity / Match_Distance; + } + + /// Initialise the alphabet for the Bitap algorithm. + /// [pattern] is the the text to encode. + /// Returns a Map of character locations. + Map _match_alphabet(String pattern) { + final s = HashMap(); + for (int i = 0; i < pattern.length; i++) { + s[pattern[i]] = 0; + } + for (int i = 0; i < pattern.length; i++) { + s[pattern[i]] = s[pattern[i]]! | (1 << (pattern.length - i - 1)); + } + return s; + } + + /// Hack to allow unit tests to call private method. Do not use. + Map test_match_alphabet(String pattern) { + return _match_alphabet(pattern); + } +} diff --git a/lib/widgets/about/translators.dart b/lib/widgets/about/translators.dart index 3a5040b04..403cab7b1 100644 --- a/lib/widgets/about/translators.dart +++ b/lib/widgets/about/translators.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/basic/animated_text.dart'; +import 'package:aves/widgets/common/basic/text/change_highlight.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/lib/widgets/common/action_mixins/feedback.dart b/lib/widgets/common/action_mixins/feedback.dart index eff79b795..2ccd754c4 100644 --- a/lib/widgets/common/action_mixins/feedback.dart +++ b/lib/widgets/common/action_mixins/feedback.dart @@ -8,8 +8,8 @@ import 'package:aves/services/accessibility_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/action_mixins/overlay_snack_bar.dart'; -import 'package:aves/widgets/common/basic/animated_text.dart'; import 'package:aves/widgets/common/basic/circle.dart'; +import 'package:aves/widgets/common/basic/text/change_highlight.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:flutter/foundation.dart'; @@ -364,7 +364,7 @@ class _FeedbackMessageState extends State<_FeedbackMessage> with SingleTickerPro // progress color is provided by the caller, // because we cannot use the app context theme here foreground: widget.progressColor, - center: AnimatedText( + center: ChangeHighlightText( '${(remainingDurationMillis / 1000).ceil()}', style: contentTextStyle.copyWith( shadows: [ diff --git a/lib/widgets/common/basic/text/animated_diff.dart b/lib/widgets/common/basic/text/animated_diff.dart new file mode 100644 index 000000000..624ed7d3b --- /dev/null +++ b/lib/widgets/common/basic/text/animated_diff.dart @@ -0,0 +1,276 @@ +import 'dart:math'; + +import 'package:aves/utils/diff_match.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'package:tuple/tuple.dart'; + +class AnimatedDiffText extends StatefulWidget { + final String text; + final TextStyle? textStyle; + final StrutStyle? strutStyle; + final Curve curve; + final Duration duration; + + const AnimatedDiffText( + this.text, { + super.key, + this.textStyle, + this.strutStyle, + this.curve = Curves.easeInOutCubic, + required this.duration, + }); + + @override + State createState() => _AnimatedDiffTextState(); +} + +class _AnimatedDiffTextState extends State with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _animation; + final List<_TextDiff> _diffs = []; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + _animation = CurvedAnimation(parent: _controller, curve: widget.curve); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _computeDiff(widget.text, widget.text); + } + + @override + void didUpdateWidget(covariant AnimatedDiffText oldWidget) { + super.didUpdateWidget(oldWidget); + final oldText = oldWidget.text; + final newText = widget.text; + if (oldText != newText) { + _computeDiff(oldText, newText); + _controller.forward(from: 0); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Text.rich( + TextSpan( + children: _diffs.map((diff) { + final oldText = diff.item1; + final newText = diff.item2; + final oldWidth = diff.item3; + final newWidth = diff.item4; + final text = (_animation.value == 0 ? oldText : newText) ?? ''; + return WidgetSpan( + child: AnimatedSize( + key: ValueKey(diff), + curve: widget.curve, + duration: widget.duration, + child: AnimatedSwitcher( + duration: widget.duration, + switchInCurve: widget.curve, + switchOutCurve: widget.curve, + layoutBuilder: (currentChild, previousChildren) { + return Stack( + alignment: Alignment.center, + children: [ + ...previousChildren.map( + (child) => ConstrainedBox( + constraints: BoxConstraints( + maxWidth: min(oldWidth, newWidth), + ), + child: child, + ), + ), + if (currentChild != null) currentChild, + ], + ); + }, + child: Text( + text, + key: Key(text), + ), + ), + ), + ); + }).toList(), + ), + strutStyle: widget.strutStyle, + ); + }, + ); + } + + double textWidth(String text) { + final para = RenderParagraph( + TextSpan(text: text, style: widget.textStyle), + textDirection: Directionality.of(context), + textScaleFactor: MediaQuery.textScaleFactorOf(context), + strutStyle: widget.strutStyle, + )..layout(const BoxConstraints(), parentUsesSize: true); + return para.getMaxIntrinsicWidth(double.infinity); + } + + // use an adaptation of Google's `Diff Match and Patch` + // as package `diffutil_dart` (as of v3.0.0) is unreliable + void _computeDiff(String oldText, String newText) { + final oldCharacters = oldText.characters.join(); + final newCharacters = newText.characters.join(); + + final dmp = DiffMatchPatch(); + final d = dmp.diff_main(oldCharacters, newCharacters); + dmp.diff_cleanupSemantic(d); + + _diffs + ..clear() + ..addAll(d.map((diff) { + final text = diff.text; + switch (diff.operation) { + case Operation.delete: + return Tuple4(text, null, textWidth(text), .0); + case Operation.insert: + return Tuple4(null, text, .0, textWidth(text)); + case Operation.equal: + default: + final width = textWidth(text); + return Tuple4(text, text, width, width); + } + }).fold>([], (prev, v) { + if (prev.isNotEmpty) { + final last = prev.last; + final prevNewText = last.item2; + if (prevNewText == null) { + // previous diff is a deletion + final thisOldText = v.item1; + if (thisOldText == null) { + // this diff is an insertion + // merge deletion and insertion as a change operation + final change = Tuple4(last.item1, v.item2, last.item3, v.item4); + return [...prev.take(prev.length - 1), change]; + } + } + } + return [...prev, v]; + })); + } + +// void _computeDiff(String oldText, String newText) { +// final oldCharacters = oldText.characters.toList(); +// final newCharacters = newText.characters.toList(); +// final diffResult = calculateListDiff(oldCharacters, newCharacters, detectMoves: false); +// final updates = diffResult.getUpdatesWithData().toList(); +// List diffs = []; +// DataDiffUpdate? pendingUpdate; +// int lastPos = oldCharacters.length; +// void addKeep(int pos) { +// if (pos < lastPos) { +// final text = oldCharacters.sublist(pos, lastPos).join(); +// final width = textWidth(text); +// diffs.insert(0, Tuple4(text, text, width, width)); +// lastPos = pos; +// } +// } +// +// void commit(DataDiffUpdate? update) { +// update?.when( +// insert: (pos, data) { +// addKeep(pos); +// diffs.insert(0, Tuple4(null, data, 0, textWidth(data))); +// lastPos = pos; +// }, +// remove: (pos, data) { +// addKeep(pos + data.length); +// diffs.insert(0, Tuple4(data, null, textWidth(data), 0)); +// lastPos = pos; +// }, +// change: (pos, oldData, newData) { +// addKeep(pos + oldData.length); +// diffs.insert(0, Tuple4(oldData, newData, textWidth(oldData), textWidth(newData))); +// lastPos = pos; +// }, +// move: (from, to, data) { +// assert(false, '`move` update: from=$from, to=$from, data=$data'); +// }, +// ); +// } +// +// for (var update in updates) { +// update.when( +// insert: (pos, data) { +// if (pendingUpdate == null) { +// pendingUpdate = update; +// return; +// } +// if (pendingUpdate is DataInsert) { +// final pendingInsert = pendingUpdate as DataInsert; +// if (pendingInsert.position == pos) { +// // merge insertions +// pendingUpdate = DataInsert(position: pos, data: data + pendingInsert.data); +// return; +// } +// } else if (pendingUpdate is DataRemove) { +// final pendingRemove = pendingUpdate as DataRemove; +// if (pendingRemove.position == pos) { +// // convert to change +// pendingUpdate = DataChange(position: pos, oldData: pendingRemove.data, newData: data); +// return; +// } +// } else if (pendingUpdate is DataChange) { +// final pendingChange = pendingUpdate as DataChange; +// if (pendingChange.position == pos) { +// // merge changes +// pendingUpdate = DataChange(position: pos, oldData: pendingChange.oldData, newData: data + pendingChange.newData); +// return; +// } +// } +// commit(pendingUpdate); +// pendingUpdate = update; +// }, +// remove: (pos, data) { +// if (pendingUpdate == null) { +// pendingUpdate = update; +// return; +// } +// if (pendingUpdate is DataRemove) { +// final pendingRemove = pendingUpdate as DataRemove; +// if (pendingRemove.position == pos + data.length) { +// // merge removals +// pendingUpdate = DataRemove(position: pos, data: data + pendingRemove.data); +// return; +// } +// } +// commit(pendingUpdate); +// pendingUpdate = update; +// }, +// change: (pos, oldData, newData) { +// assert(false, '`change` update: from=$pos, oldData=$oldData, newData=$newData'); +// }, +// move: (from, to, data) { +// assert(false, '`move` update: from=$from, to=$from, data=$data'); +// }, +// ); +// } +// commit(pendingUpdate); +// addKeep(0); +// _diffs +// ..clear() +// ..addAll(diffs); +// } +} + +typedef _TextDiff = Tuple4; diff --git a/lib/widgets/common/basic/text_background_painter.dart b/lib/widgets/common/basic/text/background_painter.dart similarity index 100% rename from lib/widgets/common/basic/text_background_painter.dart rename to lib/widgets/common/basic/text/background_painter.dart diff --git a/lib/widgets/common/basic/animated_text.dart b/lib/widgets/common/basic/text/change_highlight.dart similarity index 85% rename from lib/widgets/common/basic/animated_text.dart rename to lib/widgets/common/basic/text/change_highlight.dart index c4b1a2eca..eaebecf12 100644 --- a/lib/widgets/common/basic/animated_text.dart +++ b/lib/widgets/common/basic/text/change_highlight.dart @@ -1,13 +1,13 @@ import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -class AnimatedText extends StatefulWidget { +class ChangeHighlightText extends StatefulWidget { final String data; final TextStyle style, changedStyle; final Curve curve; final Duration duration; - const AnimatedText( + const ChangeHighlightText( this.data, { super.key, required this.style, @@ -17,10 +17,10 @@ class AnimatedText extends StatefulWidget { }); @override - State createState() => _AnimatedTextState(); + State createState() => _ChangeHighlightTextState(); } -class _AnimatedTextState extends State with SingleTickerProviderStateMixin { +class _ChangeHighlightTextState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; late final Animation _style; @@ -40,7 +40,7 @@ class _AnimatedTextState extends State with SingleTickerProviderSt } @override - void didUpdateWidget(AnimatedText oldWidget) { + void didUpdateWidget(ChangeHighlightText oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.data != widget.data) { _controller diff --git a/lib/widgets/common/basic/outlined_text.dart b/lib/widgets/common/basic/text/outlined.dart similarity index 100% rename from lib/widgets/common/basic/outlined_text.dart rename to lib/widgets/common/basic/text/outlined.dart diff --git a/lib/widgets/common/identity/aves_caption.dart b/lib/widgets/common/identity/aves_caption.dart index 470357ab7..68bf75e60 100644 --- a/lib/widgets/common/identity/aves_caption.dart +++ b/lib/widgets/common/identity/aves_caption.dart @@ -1,5 +1,5 @@ import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/common/basic/animated_text.dart'; +import 'package:aves/widgets/common/basic/text/change_highlight.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -16,7 +16,7 @@ class AvesCaption extends StatelessWidget { final theme = Theme.of(context); final subtitleStyle = theme.textTheme.bodySmall!; final subtitleChangeShadowColor = theme.colorScheme.onPrimary; - return AnimatedText( + return ChangeHighlightText( // provide key to refresh on theme brightness change key: ValueKey(subtitleChangeShadowColor), data, diff --git a/lib/widgets/common/identity/highlight_title.dart b/lib/widgets/common/identity/highlight_title.dart index f87769c39..657d27106 100644 --- a/lib/widgets/common/identity/highlight_title.dart +++ b/lib/widgets/common/identity/highlight_title.dart @@ -3,7 +3,7 @@ import 'dart:ui'; import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/colors.dart'; -import 'package:aves/widgets/common/basic/outlined_text.dart'; +import 'package:aves/widgets/common/basic/text/outlined.dart'; import 'package:aves/widgets/common/fx/highlight_decoration.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; diff --git a/lib/widgets/common/map/leaflet/scale_layer.dart b/lib/widgets/common/map/leaflet/scale_layer.dart index 3e3151f65..d305941e1 100644 --- a/lib/widgets/common/map/leaflet/scale_layer.dart +++ b/lib/widgets/common/map/leaflet/scale_layer.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'package:aves/model/settings/enums/enums.dart'; -import 'package:aves/widgets/common/basic/outlined_text.dart'; +import 'package:aves/widgets/common/basic/text/outlined.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/plugin_api.dart'; import 'package:latlong2/latlong.dart'; diff --git a/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart b/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart index e694c8c6a..18c2a1e83 100644 --- a/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart +++ b/lib/widgets/dialogs/entry_editors/remove_metadata_dialog.dart @@ -5,7 +5,7 @@ import 'package:aves/model/settings/settings.dart'; import 'package:aves/ref/brand_colors.dart'; import 'package:aves/theme/colors.dart'; import 'package:aves/theme/durations.dart'; -import 'package:aves/widgets/common/basic/outlined_text.dart'; +import 'package:aves/widgets/common/basic/text/outlined.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/highlight_decoration.dart'; import 'package:aves/widgets/common/identity/highlight_title.dart'; diff --git a/lib/widgets/settings/video/subtitle_sample.dart b/lib/widgets/settings/video/subtitle_sample.dart index 1a2af438f..e41ab0848 100644 --- a/lib/widgets/settings/video/subtitle_sample.dart +++ b/lib/widgets/settings/video/subtitle_sample.dart @@ -1,8 +1,8 @@ import 'package:aves/model/settings/enums/enums.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/utils/constants.dart'; -import 'package:aves/widgets/common/basic/outlined_text.dart'; -import 'package:aves/widgets/common/basic/text_background_painter.dart'; +import 'package:aves/widgets/common/basic/text/background_painter.dart'; +import 'package:aves/widgets/common/basic/text/outlined.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/fx/borders.dart'; import 'package:aves/widgets/viewer/visual/subtitle/subtitle.dart'; diff --git a/lib/widgets/viewer/overlay/details/rating_tags.dart b/lib/widgets/viewer/overlay/details/rating_tags.dart index 2b8dd0fb1..c96f4e877 100644 --- a/lib/widgets/viewer/overlay/details/rating_tags.dart +++ b/lib/widgets/viewer/overlay/details/rating_tags.dart @@ -1,10 +1,13 @@ import 'package:aves/model/entry.dart'; +import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/utils/constants.dart'; +import 'package:aves/widgets/common/basic/text/animated_diff.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/viewer/overlay/details/details.dart'; import 'package:decorated_icon/decorated_icon.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class OverlayRatingTagsRow extends AnimatedWidget { final AvesEntry entry; @@ -33,16 +36,25 @@ class OverlayRatingTagsRow extends AnimatedWidget { final tags = entry.tags.join(Constants.separator); final hasTags = tags.isNotEmpty; + final animationDuration = context.select((v) => v.textDiffAnimation); return Row( children: [ - if (ratingString.isNotEmpty) ...[ - Text(ratingString, strutStyle: Constants.overflowStrutStyle), - if (hasTags) const Text(Constants.separator), - ], + AnimatedDiffText( + ratingString, + strutStyle: Constants.overflowStrutStyle, + duration: animationDuration, + ), if (hasTags) ...[ + if (ratingString.isNotEmpty) const Text(Constants.separator), DecoratedIcon(AIcons.tag, size: ViewerDetailOverlayContent.iconSize, shadows: ViewerDetailOverlayContent.shadows(context)), const SizedBox(width: ViewerDetailOverlayContent.iconPadding), - Expanded(child: Text(tags, strutStyle: Constants.overflowStrutStyle)), + Expanded( + child: AnimatedDiffText( + tags, + strutStyle: Constants.overflowStrutStyle, + duration: animationDuration, + ), + ), ], ], ); diff --git a/lib/widgets/viewer/visual/subtitle/subtitle.dart b/lib/widgets/viewer/visual/subtitle/subtitle.dart index 16a1a60d3..dbb32113a 100644 --- a/lib/widgets/viewer/visual/subtitle/subtitle.dart +++ b/lib/widgets/viewer/visual/subtitle/subtitle.dart @@ -1,7 +1,7 @@ import 'package:aves/model/settings/enums/subtitle_position.dart'; import 'package:aves/model/settings/settings.dart'; -import 'package:aves/widgets/common/basic/outlined_text.dart'; -import 'package:aves/widgets/common/basic/text_background_painter.dart'; +import 'package:aves/widgets/common/basic/text/background_painter.dart'; +import 'package:aves/widgets/common/basic/text/outlined.dart'; import 'package:aves/widgets/viewer/video/controller.dart'; import 'package:aves/widgets/viewer/visual/state.dart'; import 'package:aves/widgets/viewer/visual/subtitle/ass_parser.dart';