import 'dart:collection'; import 'dart:io'; import 'dart:async'; import 'dart:isolate'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:subwrite/annotation.dart'; import 'package:subwrite/section.dart'; import 'package:subwrite/sourcelink.dart'; import 'package:subwrite/spellcheck/spellchecker.dart'; import 'package:subwrite/view.dart'; import 'cursor_provider.dart'; import 'line_info.dart'; class LineFile extends ChangeNotifier { List backingLines = List.empty(growable: true); CursorProvider cursor = CursorProvider(); FocusNode focus = FocusNode(); late String path; ItemScrollController scrollController = ItemScrollController(); var status = ""; var showSuggestions = false; SpellChecker? spellchecker; buildDictionary() async { ReceivePort receivePort = ReceivePort(); receivePort.listen((dynamic message) { updateStatus(message as double); }); status = "loading dictionary"; notifyListeners(); compute(SpellChecker.load, DictionaryLoadRequest("dicts/english.txt", receivePort.sendPort)).then( (value) { spellchecker = value; status = "dictionary loaded"; status = "spellchecking document"; updateStatus(0.0); notifyListeners(); spellCheckAll().then((value) { status = "spellchecking complete"; updateStatus(1.0); notifyListeners(); }); notifyListeners(); }, ); } void updateStatus(double update) { statusProgress = update; print("New Progress Status...$statusProgress"); notifyListeners(); } double statusProgress = 0.0; void cancelSuggestions() { suggestionIndex = 0; suggestLine = 0; suggestCol = 0; showSuggestions = false; notifyListeners(); } int suggestLine = 0; int suggestCol = 0; void showSuggestionsFor(int line, int col) { if (showSuggestions == false) { if (line != suggestLine || col != suggestCol) { suggestLine = line; suggestCol = col; showSuggestions = true; loadSuggestions(); notifyListeners(); } } } ScrollController suggestionListController = ScrollController(initialScrollOffset: 0.0); int suggestionIndex = 0; List suggestions = List.empty(); void suggestListUp() { suggestionIndex = (suggestionIndex - 1) % suggestions.length; if (suggestions.length - suggestionIndex >= 14 || suggestionIndex == 0 || suggestionIndex == suggestions.length - 1) { suggestionListController.animateTo(suggestionIndex * 13.0, duration: const Duration(milliseconds: 100), curve: Curves.decelerate); } notifyListeners(); } void suggestListDown() { suggestionIndex = (suggestionIndex + 1) % suggestions.length; if (suggestions.length - suggestionIndex >= 14 || suggestionIndex == 0 || suggestionIndex == suggestions.length - 1) { suggestionListController.animateTo(suggestionIndex * 13.0, duration: const Duration(milliseconds: 100), curve: Curves.decelerate); } notifyListeners(); } void suggestListInsert() { // TODO replace } void loadSuggestions() { List suggest = List.empty(growable: true); if (suggestLine > 0) { List suggestions = backingLines[suggestLine - 1] .annotations .where((element) => element.tool == "spellcheck") .where((element) => suggestCol >= element.sourceLink.colStart! && suggestCol < element.sourceLink.colEnd!) .toList(); if (suggestions.isNotEmpty) { var words = suggestions.first.description!.split(":"); for (var i = 0; i < words.length - 1; i++) { var spell = words[i]; if (spell.trim().isNotEmpty) { suggest.add(Suggestion("spelling", spell, correctSpelling, suggestions.first.sourceLink)); } } suggest.add(Suggestion("addto", words[words.length - 1], customDic, suggestions.first.sourceLink)); this.suggestions = suggest; notifyListeners(); } } } void openFile(String path, int startLine) { // reset path this.path = path; // purge backinglines... backingLines = List.empty(growable: true); status = "Opened $path"; File f = File(path); var line = 0; f.readAsLinesSync().forEach((text) { backingLines.insert(line, LineInfo(path, line + 1, text.replaceAll("\t", " ").trimRight())); line += 1; }); File notesFile = File("$path.subwrite.notes"); if (notesFile.existsSync()) { notesFile.readAsLinesSync().forEach((notetext) { var parts = notetext.split(":"); var line = int.parse(parts[0]); var note = parts.sublist(1).join(":"); addNoteAtLine(line, note); }); } // handle the empty file case if (backingLines.isEmpty) { backingLines.insert(0, LineInfo(path, 1, "")); } parse(); cursor.moveCursorToDocStart(); focus.requestFocus(); notifyListeners(); } KeyEventResult handleKey(RawKeyEvent event) { // only process key down events... if (event.runtimeType.toString() == 'RawKeyDownEvent') { if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { cursor.moveCursor(-1, 0, backingLines, keepAnchor: event.isShiftPressed); cursor.publishCursor(backingLines); cancelSuggestions(); } else if (event.logicalKey == LogicalKeyboardKey.arrowRight) { cursor.moveCursor(1, 0, backingLines, keepAnchor: event.isShiftPressed); cursor.publishCursor(backingLines); cancelSuggestions(); } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { if (showSuggestions == false) { cursor.moveCursor(0, -1, backingLines, keepAnchor: event.isShiftPressed); cursor.publishCursor(backingLines); } else { suggestListUp(); } } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { if (showSuggestions == false) { cursor.moveCursor(0, 1, backingLines, keepAnchor: event.isShiftPressed); cursor.publishCursor(backingLines); } else { suggestListDown(); } } else if (event.logicalKey == LogicalKeyboardKey.home) { cancelSuggestions(); cursor.moveCursorToLineStart(); cursor.clearSelection(); cursor.publishCursor(backingLines); } else if (event.logicalKey == LogicalKeyboardKey.end) { cancelSuggestions(); cursor.moveCursorToLineEnd(backingLines[cursor.line - 1].text.length); cursor.clearSelection(); cursor.publishCursor(backingLines); } else if (event.logicalKey == LogicalKeyboardKey.enter) { if (showSuggestions == false) { insertLine(); cursor.clearSelection(); cursor.publishCursor(backingLines); } else { suggestListInsert(); } } else if (event.logicalKey == LogicalKeyboardKey.tab) { insertChar(" "); cursor.publishCursor(backingLines); cancelSuggestions(); } else if (event.logicalKey == LogicalKeyboardKey.delete) { handleDelete(); cursor.clearSelection(); cursor.publishCursor(backingLines); cancelSuggestions(); } else if (event.logicalKey == LogicalKeyboardKey.backspace) { handleBackspace(); cursor.clearSelection(); cursor.publishCursor(backingLines); cancelSuggestions(); } else if (event.logicalKey == LogicalKeyboardKey.escape) { showSuggestions = false; notifyListeners(); } else if (event.logicalKey == LogicalKeyboardKey.f4) { status = "spellchecking document"; notifyListeners(); updateStatus(0.0); spellCheckAll().then((value) { status = "spellcheck complete"; notifyListeners(); }); } else { if (event.isControlPressed) { if (event.logicalKey == LogicalKeyboardKey.space) { notifyListeners(); } if (event.character != null && event.character == 's') { saveFile(); notifyListeners(); } if (event.character != null && event.character == 'c') { copy(); } if (event.character != null && event.character == 'v') { paste(); notifyListeners(); } } else { if (event.logicalKey == LogicalKeyboardKey.space) { insertChar(" "); } else if (event.character != null) { insertChar(event.character!); } } cursor.publishCursor(backingLines); } } parse(); spellcheck(cursor.line - 1).then((value) { notifyListeners(); }); return KeyEventResult.handled; } void saveFile() { status = "Saving..."; notifyListeners(); String content = ''; String notes = ''; for (var l in backingLines) { content += '${l.text}\n'; for (var annot in l.annotations) { if (annot.description != null && annot.tool != "spellcheck") { notes += '${annot.sourceLink.lineStart}:${annot.description}\n'; } } } File("$path.subwrite.notes").writeAsStringSync(notes); File(path).writeAsStringSync(content); status = "Saved"; notifyListeners(); } void handleDelete() { if (cursor.hasSelection()) { deleteSelected(); return; } // if at the end of the line then we need to move the next line onto // our line, and delete what remains if (cursor.column == backingLines[cursor.line - 1].text.length) { backingLines[cursor.line - 1].text += backingLines[cursor.line].text; backingLines[cursor.line - 1].notifyListeners(); backingLines.removeAt(cursor.line); renumberFrom(cursor.line); } else { // otherwise simply delete a char var beforeDeletedChar = backingLines[cursor.line - 1].text.substring(0, cursor.column); var afterDeletedChar = backingLines[cursor.line - 1].text.substring(cursor.column + 1); backingLines[cursor.line - 1].text = beforeDeletedChar + afterDeletedChar; backingLines[cursor.line - 1].notifyListeners(); } } void deleteSelected() { var ncursor = cursor.normalized(); if (ncursor.hasSelection()) { if (ncursor.anchorLine == ncursor.line) { // simple case, just trim column var beforeDeletedChar = backingLines[ncursor.line - 1].text.substring(0, ncursor.column); var afterDeletedChar = backingLines[ncursor.line - 1].text.substring(ncursor.anchorColumn); backingLines[ncursor.line - 1].text = beforeDeletedChar + afterDeletedChar; backingLines[ncursor.line - 1].notifyListeners(); cursor.clearSelection(); } else { // First Line var withDeletedRemainder = backingLines[ncursor.line - 1].text.substring(0, ncursor.column); var withDeletedPrefix = backingLines[ncursor.anchorLine - 1].text.substring(ncursor.anchorColumn); backingLines[ncursor.line - 1].text = withDeletedRemainder + withDeletedPrefix; // Delete 2nd to n-1 Lines for (var line = (ncursor.anchorLine - 1); line >= ncursor.line; line -= 1) { backingLines.removeAt(line); } renumberFrom(ncursor.line); cursor.clearSelection(); } } cursor.clearSelection(); } void handleBackspace() { if (cursor.hasSelection()) { deleteSelected(); return; } if (cursor.column > 0) { var beforeDeletedChar = backingLines[cursor.line - 1].text.substring(0, cursor.column - 1); var afterDeletedChar = backingLines[cursor.line - 1].text.substring(cursor.column); backingLines[cursor.line - 1].text = beforeDeletedChar + afterDeletedChar; backingLines[cursor.line - 1].notifyListeners(); cursor.moveCursor(-1, 0, backingLines); } else if (cursor.line > 1) { // join current line to previous line cursor.moveCursor(0, -1, backingLines); cursor.moveCursorToLineEnd(backingLines[cursor.line - 1].text.length); backingLines[cursor.line - 1].text += backingLines[cursor.line].text; backingLines[cursor.line - 1].notifyListeners(); // delete current line backingLines.removeAt(cursor.line); renumberFrom(cursor.line); // renumber } } void insertChar(String ch) { status = "Unsaved Changes"; notifyListeners(); var beforeInsertedChar = backingLines[cursor.line - 1].text.substring(0, cursor.column); var afterInsertedChar = backingLines[cursor.line - 1].text.substring(cursor.column); backingLines[cursor.line - 1].text = beforeInsertedChar + ch + afterInsertedChar; cursor.moveCursor(ch.length, 0, backingLines); backingLines[cursor.line - 1].notify(); } void insertLine() { status = "Unsaved Changes"; notifyListeners(); // get the content that will be moved to the next line var restOfLine = backingLines[cursor.line - 1].text.substring(cursor.column); // split off the line after the cursor backingLines[cursor.line - 1].text = backingLines[cursor.line - 1].text.substring(0, cursor.column); // insert a line directly into this position backingLines.insert(cursor.line, LineInfo(path, cursor.line + 1, restOfLine)); // Move the Cursor Down cursor.moveCursor(0, 1, backingLines); // renumber all the lines afterwards renumberFrom(cursor.line - 1); cursor.moveCursorToLineStart(); } void renumberFrom(int startLine) { // Catch many unsaved changes here... status = "Unsaved Changes"; notifyListeners(); for (int line = startLine; line < backingLines.length; line++) { backingLines[line].lineNumber = line + 1; backingLines[line].notifyListeners(); } notifyListeners(); } int lines() { return backingLines.length; } final _boldRegex = RegExp( r'(\*\*([^*]*)\*\*)', caseSensitive: false, dotAll: true, ); final _codeRegex = RegExp( r'(\`([^`]*)\`)', caseSensitive: false, dotAll: true, ); final _imageRegex = RegExp( r'!\[([^\[]*)\]\((.*)\)', caseSensitive: false, dotAll: true, ); List
sections = List.empty(growable: true); Future spellcheck(int i) async { var line = backingLines[i]; line.annotations.removeWhere((element) => element.tool == "spellcheck"); if (spellchecker == null) { return; } List annotations = List.empty(growable: true); var wordStart = 0; var wordEnd = 0; var lowerCase = line.text.toLowerCase(); for (var c = 0; c < lowerCase.length; c++) { if (lowerCase.substring(c, c + 1) == " ") { wordEnd = c; var annotationStart = wordStart; var word = lowerCase.substring(wordStart, wordEnd); wordStart = c + 1; if (word.endsWith(".")) { word = word.substring(0, word.length - 1); } if (word.endsWith(":")) { word = word.substring(0, word.length - 1); } if (word.endsWith(";")) { word = word.substring(0, word.length - 1); } if (word.endsWith(",")) { word = word.substring(0, word.length - 1); } // clean up quotes word = word.replaceAll("\"", ""); word = word.replaceAll("'", ""); word = word.replaceAll("`", ""); word = word.replaceAll("(", ""); word = word.replaceAll(")", ""); word = word.replaceAll("[", ""); word = word.replaceAll("]", ""); word = word.replaceAll("^", ""); word = word.replaceAll("*", ""); word = word.replaceAll("?", ""); word = word.replaceAll("!", ""); word = word.replaceAll("#", ""); word = word.replaceAll("*", ""); // ignore numbers if (word.contains(RegExp(r'[0-9]'))) { continue; } // ignore code... if (word.contains("/") | word.contains("=") | word.contains(">") || word.contains("<") || word.contains("@") || word.contains("`") || word.contains(";")) { continue; } if (word.length < 2 || word.length > 10) { continue; } HashSet? candidates = spellchecker!.candidates(word); if (candidates == null) { await compute(mutations2, word).then((results) { spellchecker!.dictionary.putIfAbsent(word, () => HashSet()); results.forEach((element) { if (spellchecker!.known.contains(element)) { spellchecker!.dictionary[word]!.add(element); } }); }); } // Candidates should now not be null... candidates = spellchecker!.candidates(word); if (candidates!.contains(word) == false) { annotations.add(LineAnnotation(SourceLink("", lineStart: 0, colStart: annotationStart, colEnd: wordEnd + 1, lineEnd: 0), "spellcheck", AnnotationType.highlight, "spellcheck", description: "${candidates.join(":")}:$word")); } } for (var annot in annotations) { annot.sourceLink = SourceLink(path, lineStart: i + 1, lineEnd: i + 1, colStart: annot.sourceLink.colStart, colEnd: annot.sourceLink.colEnd); backingLines[i].annotations.add(annot); } notifyListeners(); } return; } // rebuild the annotations manager void parse() { sections.clear(); for (var i = 0; i < backingLines.length; i++) { var line = backingLines[i]; backingLines[i].annotations.removeWhere((element) => element.tool == "markdown"); if (line.text.startsWith("//")) { backingLines[i] .annotations .add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: 0, colEnd: line.text.length + 1, lineEnd: i), "markdown", AnnotationType.highlight, "comment")); if (line.text.contains("TODO")) { backingLines[i] .annotations .add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: 0, colEnd: line.text.length + 1, lineEnd: i), "markdown", AnnotationType.todo, "todo")); } } if (line.text.startsWith("#")) { // this is a header backingLines[i] .annotations .add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: 0, colEnd: line.text.length + 1, lineEnd: i), "markdown", AnnotationType.highlight, "header")); var level = 0; for (var lvl = 0; lvl < 6; lvl++) { if (line.text.length > lvl) { if (line.text[lvl] == '#') { level += 1; } else { break; } } } sections.add(Section(backingLines[i].lineNumber, level, line.text.substring(level + 1))); } if (line.text.startsWith(" ")) { backingLines[i] .annotations .add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: 0, colEnd: line.text.length + 1, lineEnd: i), "markdown", AnnotationType.highlight, "code")); } var imageMatch = _imageRegex.matchAsPrefix(line.text); if (imageMatch != null) { backingLines[i].annotations.add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, lineEnd: i), "markdown", AnnotationType.image, imageMatch.group(2)!)); } _boldRegex.allMatches(backingLines[i].text).forEach((match) { backingLines[i] .annotations .add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: match.start, colEnd: match.end + 1, lineEnd: i), "markdown", AnnotationType.highlight, "bold")); }); _codeRegex.allMatches(backingLines[i].text).forEach((match) { backingLines[i] .annotations .add(LineAnnotation(SourceLink(path, lineStart: backingLines[i].lineNumber, colStart: match.start, colEnd: match.end + 1, lineEnd: i), "markdown", AnnotationType.highlight, "code")); }); } notifyListeners(); } void copy() { var ncursor = cursor.normalized(); var firstLine = "${backingLines[ncursor.line - 1].text.substring(ncursor.column)}\n"; for (var line = ncursor.line + 1; line < ncursor.anchorLine; line++) { firstLine += "${backingLines[line - 1].text}\n"; } var textToCopy = firstLine; if (ncursor.anchorLine != ncursor.line) { var lastLine = backingLines[ncursor.anchorLine - 1].text.substring(0, ncursor.anchorColumn); textToCopy += lastLine; } Clipboard.setData(ClipboardData(text: textToCopy)); } void paste() { deleteSelected(); // if we have a selection we need to delete it before pasting new text Clipboard.getData("text/plain").then((value) { if (value != null && value.text != null) { for (var i = 0; i < value.text!.length; i++) { var char = value.text![i]; if (char == "\n") { insertLine(); } else { insertChar(char); } } } }); } void mouseDownUpdate(int line, int col) { focus.requestFocus(); showSuggestions = false; cursor.mouseDownUpdate(line, col, backingLines); notifyListeners(); } void mouseDownMoveUpdate(int col) { cursor.mouseDownMoveUpdate(col, backingLines); } void mouseDownEnd() { cursor.mouseDownEnd(); } void moveLines(int startLine, int endLine, int newLine) { List insert = List.empty(growable: true); // backup the section for (int line = startLine; line < endLine; line++) { insert.add(backingLines[line - 1]); } // remove the section for (int line = startLine; line < endLine; line++) { backingLines.removeAt(startLine - 1); } // if we want to put a line that was on e.g. 5 on 10 then we have to remove 5... // (unlike if it was on 10 and we want to put it on 5...it hasn't changed) if (newLine > startLine) { newLine -= endLine - startLine; } // reinsert for (int line = 0; line < insert.length; line++) { backingLines.insert(newLine - 1 + line, insert[line]); } renumberFrom(0); parse(); notifyListeners(); } addNote(String note) { addNoteAtLine(cursor.line, note); } addNoteAtLine(int line, String note) { var notetype = "note"; if (note.startsWith("[incomplete]")) { notetype = "incomplete"; } else if (note.startsWith("[edit]")) { notetype = "edit"; } backingLines[line - 1].annotations.add(LineAnnotation( SourceLink( path, lineStart: line, ), "notes", AnnotationType.note, notetype, description: note)); notifyListeners(); } int wordCount() { return backingLines.map((e) => e.text.trim().split(" ").length).fold(0, (previousValue, element) => previousValue + element); } void setSuggestIndex(int index) { suggestionIndex = index; notifyListeners(); } Future spellCheckAll() async { for (int i = 0; i < backingLines.length; i++) { await spellcheck(i); updateStatus(i.toDouble() / backingLines.length.toDouble()); } return Future.value(true); } customDic(BuildContext context, Suggestion suggestion) { spellchecker!.addToCustomDictionary(suggestion.name); int line = suggestion.link.lineStart! - 1; spellcheck(line); } correctSpelling(BuildContext context, Suggestion suggestion) { int start = suggestion.link.colStart!; int end = suggestion.link.colEnd!; int line = suggestion.link.lineStart! - 1; backingLines[line].text = backingLines[line].text.replaceRange(start, end - 1, suggestion.name); backingLines[line].notifyListeners(); notifyListeners(); spellcheck(line); } } class SpellcheckerRequest { final String text; SpellcheckerRequest(this.text); }