From 83f5f5942e660c4ced427256c286045d3677cb9c Mon Sep 17 00:00:00 2001 From: Li Guanglin <60415467+guang-lin@users.noreply.github.com> Date: Tue, 12 Sep 2023 04:22:40 +0800 Subject: [PATCH] Line numbers improvements, by @harshad1 @guang-lin @gsantner (PR #2090) --- .../activity/DocumentEditAndViewFragment.java | 12 +- .../markor/format/TextConverterBase.java | 31 +- .../asciidoc/AsciidocTextConverter.java | 6 +- .../binary/EmbedBinaryTextConverter.java | 4 +- .../markor/format/csv/CsvTextConverter.java | 7 +- .../markdown/MarkdownTextConverter.java | 24 +- .../plaintext/PlaintextTextConverter.java | 8 +- .../format/todotxt/TodoTxtTextConverter.java | 4 +- .../wikitext/WikitextTextConverter.java | 15 +- .../markor/frontend/NewFileDialog.java | 3 + .../frontend/filesearch/FileSearchEngine.java | 22 +- .../frontend/textview/HighlightingEditor.java | 341 +++++++++++------- .../frontend/textview/TextViewUtils.java | 24 ++ .../gsantner/markor/model/AppSettings.java | 16 + 14 files changed, 316 insertions(+), 201 deletions(-) diff --git a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java index 8da684a36..a9b8639da 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java @@ -200,7 +200,7 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { _hlEditor.setTextColor(_appSettings.getEditorForegroundColor()); _hlEditor.setGravity(_appSettings.isEditorStartEditingInCenter() ? Gravity.CENTER : Gravity.NO_GRAVITY); _hlEditor.setHighlightingEnabled(_appSettings.getDocumentHighlightState(_document.getPath(), _hlEditor.getText())); - _hlEditor.setLineNumbersEnabled(_appSettings.isLineNumbersEnabled()); + _hlEditor.setLineNumbersEnabled(_appSettings.getDocumentLineNumbersEnabled(_document.getPath())); _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.getPath())); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Do not need to send contents to accessibility @@ -459,7 +459,7 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (saveDocument(false)) { TextConverterBase converter = FormatRegistry.getFormat(_document.getFormat(), activity, _document).getConverter(); _cu.shareText(getActivity(), - converter.convertMarkup(getTextString(), getActivity(), false, _document.getFile()), + converter.convertMarkup(getTextString(), getActivity(), false, _hlEditor.getLineNumbersEnabled(), _document.getFile()), "text/" + (item.getItemId() == R.id.action_share_html ? "html" : "plain") ); } @@ -563,9 +563,9 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { return true; } case R.id.action_line_numbers: { - _appSettings.setLineNumbersEnabled(!_appSettings.isLineNumbersEnabled()); - _hlEditor.setLineNumbersEnabled(_appSettings.isLineNumbersEnabled()); - _hlEditor.invalidate(); + final boolean newState = !_hlEditor.getLineNumbersEnabled(); + _appSettings.setDocumentLineNumbersEnabled(_document.getPath(), newState); + _hlEditor.setLineNumbersEnabled(newState); updateMenuToggleStates(0); return true; } @@ -758,7 +758,7 @@ private boolean isDisplayedAtMainActivity() { public void updateViewModeText() { final String text = getTextString(); - _format.getConverter().convertMarkupShowInWebView(_document, text, getActivity(), _webView, _nextConvertToPrintMode); + _format.getConverter().convertMarkupShowInWebView(_document, text, getActivity(), _webView, _nextConvertToPrintMode, _hlEditor.getLineNumbersEnabled()); } public void setViewModeVisibility(boolean show) { diff --git a/app/src/main/java/net/gsantner/markor/format/TextConverterBase.java b/app/src/main/java/net/gsantner/markor/format/TextConverterBase.java index a41ff37ec..946ce4fa2 100644 --- a/app/src/main/java/net/gsantner/markor/format/TextConverterBase.java +++ b/app/src/main/java/net/gsantner/markor/format/TextConverterBase.java @@ -22,6 +22,7 @@ import net.gsantner.markor.model.Document; import net.gsantner.opoc.format.GsTextUtils; import net.gsantner.opoc.util.GsContextUtils; +import net.gsantner.opoc.util.GsFileUtils; import java.io.File; import java.util.Date; @@ -97,19 +98,18 @@ public TextConverterBase() { * @param webView The WebView content to be shown in * @return Copy of converted html */ - public String convertMarkupShowInWebView(Document document, String content, Activity context, WebView webView, boolean isExportInLightMode) { + public String convertMarkupShowInWebView(Document document, String content, Activity context, WebView webView, boolean lightMode, boolean lineNum) { String html; try { - html = convertMarkup(content, context, isExportInLightMode, document.getFile()); + html = convertMarkup(content, context, lightMode, lineNum, document.getFile()); } catch (Exception e) { - html = "Please report at project issue tracker: " + e.toString(); + html = "Please report at project issue tracker: " + e; } - String baseFolder = ApplicationObject.settings().getNotebookDirectory().getAbsolutePath(); - if (document.getFile().getParentFile() != null) { - baseFolder = document.getFile().getParent(); + String baseFolder = document.getFile().getParent(); + if (baseFolder == null) { + baseFolder = "file://" + baseFolder + "/"; } - baseFolder = "file://" + baseFolder + "/"; webView.loadDataWithBaseURL(baseFolder, html, getContentType(), UTF_CHARSET, null); // When TOKEN_TEXT_CONVERTER_MAX_ZOOM_OUT_BY_DEFAULT is contained in text zoom out as far possible @@ -121,21 +121,16 @@ public String convertMarkupShowInWebView(Document document, String content, Acti return html; } - protected String getFileExtension(File file) { - if (file == null) { - return ""; - } - return (file.getName().contains(".") ? file.getName().substring(file.getName().lastIndexOf(".")) : "").toLowerCase(); - } - /** * Convert markup text to target format * - * @param markup Markup text - * @param context Android Context + * @param markup Markup text + * @param context Android Context + * @param lightMode + * @param lineNum * @return html as String */ - public abstract String convertMarkup(String markup, Context context, boolean isExportInLightMode, File file); + public abstract String convertMarkup(String markup, Context context, boolean lightMode, boolean lineNum, File file); protected String putContentIntoTemplate(Context context, String content, boolean isExportInLightMode, File file, String onLoadJs, String head) { final String contentLower = content.toLowerCase(); @@ -185,7 +180,7 @@ protected String putContentIntoTemplate(Context context, String content, boolean .replace(TOKEN_ACCENT_COLOR, GsTextUtils.colorToHexString(ContextCompat.getColor(context, R.color.accent))) .replace(TOKEN_TEXT_DIRECTION, _appSettings.isRenderRtl() ? "right" : "left") .replace(TOKEN_FONT, font) - .replace(TOKEN_TEXT_CONVERTER_CSS_CLASS, "format-" + getClass().getSimpleName().toLowerCase().replace("textconverter", "").replace("converter", "") + " fileext-" + getFileExtension(file).replace(".", "")) + .replace(TOKEN_TEXT_CONVERTER_CSS_CLASS, "format-" + getClass().getSimpleName().toLowerCase().replace("textconverter", "").replace("converter", "") + " fileext-" + GsFileUtils.getFilenameExtension(file).replace(".", "")) .replace(TOKEN_POST_TODAY_DATE, DateFormat.getDateFormat(context).format(new Date())) .replace(TOKEN_FILEURI_VIEWED_FILE, (file != null ? Uri.fromFile(file.getAbsoluteFile()).toString() : "file:///dummy").replace("'", "\\'").replace("\"", "\\\"")); diff --git a/app/src/main/java/net/gsantner/markor/format/asciidoc/AsciidocTextConverter.java b/app/src/main/java/net/gsantner/markor/format/asciidoc/AsciidocTextConverter.java index 287ecc776..a2a8384e9 100644 --- a/app/src/main/java/net/gsantner/markor/format/asciidoc/AsciidocTextConverter.java +++ b/app/src/main/java/net/gsantner/markor/format/asciidoc/AsciidocTextConverter.java @@ -33,7 +33,7 @@ public class AsciidocTextConverter extends TextConverterBase { public static final String HTML_ASCIIDOCJS_DARK_CSS_INCLUDE = "file:///android_asset/asciidoc/dark.css"; @Override - public String convertMarkup(String markup, Context context, boolean isExportInLightMode, File file) { + public String convertMarkup(String markup, Context context, boolean lightMode, boolean lineNum, File file) { String converted = "
\n"; String onLoadJs = "var textBase64 = `" + //convert a text to base64 to simplify supporting special characters @@ -52,10 +52,10 @@ public String convertMarkup(String markup, Context context, boolean isExportInLi //standalone : true - to generate header 1 (= title) in the page. if don't do that - title will be absent. //nofooter: true - to don't generate footer (Last updated ...). if don't do that and use standalone : true - the page will have that footer. "var html = asciidoctor.convert(utf8PlainText, {standalone : true, attributes : {nofooter: true, stylesheet: \"" + - (isExportInLightMode ? HTML_ASCIIDOCJS_DEFAULT_CSS_INCLUDE : HTML_ASCIIDOCJS_DARK_CSS_INCLUDE) + (lightMode ? HTML_ASCIIDOCJS_DEFAULT_CSS_INCLUDE : HTML_ASCIIDOCJS_DARK_CSS_INCLUDE) + "\"}});\n" + "document.getElementById(\"asciidoc_content\").innerHTML = html;"; - return putContentIntoTemplate(context, converted, isExportInLightMode, file, onLoadJs, HTML_ASCIIDOCJS_JS_INCLUDE); + return putContentIntoTemplate(context, converted, lightMode, file, onLoadJs, HTML_ASCIIDOCJS_JS_INCLUDE); } @Override diff --git a/app/src/main/java/net/gsantner/markor/format/binary/EmbedBinaryTextConverter.java b/app/src/main/java/net/gsantner/markor/format/binary/EmbedBinaryTextConverter.java index 53b64c45a..56bf3e5a6 100644 --- a/app/src/main/java/net/gsantner/markor/format/binary/EmbedBinaryTextConverter.java +++ b/app/src/main/java/net/gsantner/markor/format/binary/EmbedBinaryTextConverter.java @@ -53,7 +53,7 @@ public class EmbedBinaryTextConverter extends TextConverterBase { @SuppressWarnings({"ConstantConditions", "StringConcatenationInLoop"}) @Override - public String convertMarkup(String markup, Context context, boolean isExportInLightMode, File file) { + public String convertMarkup(String markup, Context context, boolean lightMode, boolean lineNum, File file) { String converted = "", onLoadJs = "", head = ""; if (file == null) { return ""; @@ -152,7 +152,7 @@ public String convertMarkup(String markup, Context context, boolean isExportInLi } converted += HTML101_BODY_END; - return putContentIntoTemplate(context, converted, isExportInLightMode, file, onLoadJs, head); + return putContentIntoTemplate(context, converted, lightMode, file, onLoadJs, head); } @Override diff --git a/app/src/main/java/net/gsantner/markor/format/csv/CsvTextConverter.java b/app/src/main/java/net/gsantner/markor/format/csv/CsvTextConverter.java index 1ae722618..7203bcc1d 100644 --- a/app/src/main/java/net/gsantner/markor/format/csv/CsvTextConverter.java +++ b/app/src/main/java/net/gsantner/markor/format/csv/CsvTextConverter.java @@ -18,6 +18,7 @@ import com.opencsv.CSVReaderBuilder; import com.opencsv.ICSVParser; +import net.gsantner.markor.format.TextConverterBase; import net.gsantner.markor.format.markdown.MarkdownTextConverter; import java.io.BufferedReader; @@ -33,7 +34,7 @@ * Part of Markor-Architecture implementing Preview/Export for csv. *

* Converts csv to md and let - * {@link MarkdownTextConverter#convertMarkup(String, Context, boolean, File)} + * {@link TextConverterBase#convertMarkup(String, Context, boolean, boolean, File)} * do the rest. *

* This way csv columns may contain md expressions like bold text. @@ -41,9 +42,9 @@ @SuppressWarnings("WeakerAccess") public class CsvTextConverter extends MarkdownTextConverter { @Override - public String convertMarkup(String csvMarkup, Context context, boolean isExportInLightMode, File file) { + public String convertMarkup(String csvMarkup, Context context, boolean lightMode, boolean lineNum, File file) { String mdMarkup = Csv2MdTable.toMdTable(csvMarkup); - return super.convertMarkup(mdMarkup, context, isExportInLightMode, file); + return super.convertMarkup(mdMarkup, context, lightMode, lineNum, file); } @Override diff --git a/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownTextConverter.java b/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownTextConverter.java index 9371297ac..fe582dabe 100644 --- a/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownTextConverter.java +++ b/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownTextConverter.java @@ -157,19 +157,20 @@ public class MarkdownTextConverter extends TextConverterBase { //######################## @Override - public String convertMarkup(String markup, Context context, boolean isExportInLightMode, File file) { + public String convertMarkup(String markup, Context context, boolean lightMode, boolean lineNum, File file) { String converted = "", onLoadJs = "", head = ""; - MutableDataSet options = new MutableDataSet(); + final MutableDataSet options = new MutableDataSet(); - if (_appSettings.isLineNumbersEnabled()) { + if (lineNum) { // Add code blocks Line numbers extension - ArrayList extensions = new ArrayList<>(flexmarkExtensions); + final ArrayList extensions = new ArrayList<>(flexmarkExtensions); extensions.add(LineNumbersExtension.create()); options.set(Parser.EXTENSIONS, extensions); } else { options.set(Parser.EXTENSIONS, flexmarkExtensions); } + options.set(Parser.SPACE_IN_LINK_URLS, true); // allow links like [this](some filename with spaces.md) //options.set(HtmlRenderer.SOFT_BREAK, "
\n"); // Add linefeed to html break options.set(EmojiExtension.USE_IMAGE_TYPE, EmojiImageType.UNICODE_ONLY); // Use unicode (OS/browser images) @@ -273,8 +274,7 @@ public String convertMarkup(String markup, Context context, boolean isExportInLi } // Enable View (block) code syntax highlighting - final String xt = getViewHlPrismIncludes((GsContextUtils.instance.isDarkModeEnabled(context) ? "-tomorrow" : "")); - head += xt; + head += getViewHlPrismIncludes(GsContextUtils.instance.isDarkModeEnabled(context) ? "-tomorrow" : "", lineNum); // Jekyll: Replace {{ site.baseurl }} with ..--> usually used in Jekyll blog _posts folder which is one folder below repository root, for reference to e.g. pictures in assets folder markup = markup.replace("{{ site.baseurl }}", "..").replace(TOKEN_SITE_DATE_JEKYLL, TOKEN_POST_TODAY_DATE); @@ -296,11 +296,9 @@ public String convertMarkup(String markup, Context context, boolean isExportInLi fmaText = HTML_FRONTMATTER_CONTAINER_S + fmaText + HTML_FRONTMATTER_CONTAINER_E + "\n"; } - //////////// // Markup parsing - afterwards = HTML - converted = flexmarkRenderer.withOptions(options).render(flexmarkParser.parse(markup)); - converted = fmaText + converted; + converted = fmaText + flexmarkRenderer.withOptions(options).render(flexmarkParser.parse(markup)); // After render changes: Fixes for Footnotes (converter creates footnote +
+ ref#(click) --> remove line break) if (converted.contains("footnote-")) { @@ -324,7 +322,7 @@ public String convertMarkup(String markup, Context context, boolean isExportInLi } // Deliver result - return putContentIntoTemplate(context, converted, isExportInLightMode, file, onLoadJs, head); + return putContentIntoTemplate(context, converted, lightMode, file, onLoadJs, head); } private static final Pattern linkPattern = Pattern.compile("\\[(.*?)\\]\\((.*?)(\\s+\".*\")?\\)"); @@ -360,13 +358,13 @@ private String escapeSpacesInLink(final String markup) { } @SuppressWarnings({"StringConcatenationInsideStringBufferAppend"}) - private String getViewHlPrismIncludes(final String themeName) { + private String getViewHlPrismIncludes(final String themeName, final boolean lineNum) { final StringBuilder sb = new StringBuilder(1000); sb.append(CSS_PREFIX + "prism/themes/prism" + themeName + ".min.css" + CSS_POSTFIX); sb.append(JS_PREFIX + "prism/prism.js" + JS_POSTFIX); sb.append(JS_PREFIX + "prism/plugins/autoloader/prism-autoloader.min.js" + JS_POSTFIX); - if (_appSettings.isLineNumbersEnabled()) { + if (lineNum) { sb.append(CSS_PREFIX + "prism/plugins/line-numbers/prism-line-numbers.css" + CSS_POSTFIX); sb.append(JS_PREFIX + "prism/plugins/line-numbers/prism-line-numbers.min.js" + JS_POSTFIX); } @@ -424,4 +422,4 @@ private String replaceTokens(final String markup, final Map return markupReplaced; } -} +} \ No newline at end of file diff --git a/app/src/main/java/net/gsantner/markor/format/plaintext/PlaintextTextConverter.java b/app/src/main/java/net/gsantner/markor/format/plaintext/PlaintextTextConverter.java index 6eba2d0c7..bde06ccb2 100644 --- a/app/src/main/java/net/gsantner/markor/format/plaintext/PlaintextTextConverter.java +++ b/app/src/main/java/net/gsantner/markor/format/plaintext/PlaintextTextConverter.java @@ -44,7 +44,7 @@ public class PlaintextTextConverter extends TextConverterBase { //######################## @Override - public String convertMarkup(String markup, Context context, boolean isExportInLightMode, File file) { + public String convertMarkup(String markup, Context context, boolean lightMode, boolean lineNum, File file) { String converted = "", onLoadJs = "", head = ""; final String extWithDot = GsFileUtils.getFilenameExtension(file); String tmp; @@ -65,12 +65,12 @@ public String convertMarkup(String markup, Context context, boolean isExportInLi converted += markup; } else if (extWithDot.matches(EmbedBinaryTextConverter.EXT_MATCHES_M3U_PLAYLIST)) { // Playlist: Load in Embed-Binary view-mode - return FormatRegistry.CONVERTER_EMBEDBINARY.convertMarkup(markup, context, isExportInLightMode, file); + return FormatRegistry.CONVERTER_EMBEDBINARY.convertMarkup(markup, context, lightMode, lineNum, file); } else if (EXT_CODE_HL.contains(extWithDot) || (this instanceof KeyValueTextConverter)) { // Source code: Load in Markdown view-mode & utilize code block highlighting final String hlLang = extWithDot.replace(".sh", ".bash").replace(".", ""); markup = String.format(Locale.ROOT, "```%s\n%s\n```", hlLang, markup); - return FormatRegistry.CONVERTER_MARKDOWN.convertMarkup(markup, context, isExportInLightMode, file); + return FormatRegistry.CONVERTER_MARKDOWN.convertMarkup(markup, context, lightMode, lineNum, file); } else { /////////////////////////////////////////// // Whatever else show in plaintext

 block
@@ -78,7 +78,7 @@ public String convertMarkup(String markup, Context context, boolean isExportInLi
                     + TextUtilsCompat.htmlEncode(markup)
                     + HTML101_BODY_PRE_END;
         }
-        return putContentIntoTemplate(context, converted, isExportInLightMode, file, onLoadJs, head);
+        return putContentIntoTemplate(context, converted, lightMode, file, onLoadJs, head);
     }
 
     @Override
diff --git a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtTextConverter.java b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtTextConverter.java
index 698515f6a..00c88216a 100644
--- a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtTextConverter.java
+++ b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtTextConverter.java
@@ -29,12 +29,12 @@ public class TodoTxtTextConverter extends TextConverterBase {
     //########################
 
     @Override
-    public String convertMarkup(String markup, Context context, boolean isExportInLightMode, File file) {
+    public String convertMarkup(String markup, Context context, boolean lightMode, boolean lineNum, File file) {
         String converted = "", onLoadJs = "", head = "";
         converted = HTML100_BODY_PRE_BEGIN
                 + parse(TextUtilsCompat.htmlEncode(markup))
                 + HTML101_BODY_PRE_END;
-        return putContentIntoTemplate(context, converted, isExportInLightMode, file, onLoadJs, head);
+        return putContentIntoTemplate(context, converted, lightMode, file, onLoadJs, head);
     }
 
     private String parse(String str) {
diff --git a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextTextConverter.java b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextTextConverter.java
index 033652c2d..c0598dec0 100644
--- a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextTextConverter.java
+++ b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextTextConverter.java
@@ -35,25 +35,26 @@ public class WikitextTextConverter extends TextConverterBase {
     /**
      * First, convert Wikitext to regular Markor markdown. Then, calls the regular converter.
      *
-     * @param markup              Markup text
-     * @param context             Android Context
-     * @param isExportInLightMode True if the light theme is to apply.
-     * @param file                The file to convert.
+     * @param markup     Markup text
+     * @param context    Android Context
+     * @param lightMode  True if the light theme is to apply.
+     * @param lineNum
+     * @param file       The file to convert.
      * @return HTML text
      */
     @Override
-    public String convertMarkup(String markup, Context context, boolean isExportInLightMode, File file) {
+    public String convertMarkup(String markup, Context context, boolean lightMode, boolean lineNum, File file) {
         String contentWithoutHeader = markup.replaceFirst(WikitextSyntaxHighlighter.ZIMHEADER.toString(), "");
         StringBuilder markdownContent = new StringBuilder();
 
         for (String line : contentWithoutHeader.split("\\r\\n|\\r|\\n")) {
-            String markdownEquivalentLine = getMarkdownEquivalentLine(context, file, line, isExportInLightMode);
+            String markdownEquivalentLine = getMarkdownEquivalentLine(context, file, line, lightMode);
             markdownContent.append(markdownEquivalentLine);
             markdownContent.append("  "); // line breaks must be made explicit in markdown by two spaces
             markdownContent.append(String.format("%n"));
         }
 
-        return FormatRegistry.CONVERTER_MARKDOWN.convertMarkup(markdownContent.toString(), context, isExportInLightMode, file);
+        return FormatRegistry.CONVERTER_MARKDOWN.convertMarkup(markdownContent.toString(), context, lightMode, lineNum, file);
     }
 
     private String getMarkdownEquivalentLine(final Context context, final File file, String wikitextLine, final boolean isExportInLightMode) {
diff --git a/app/src/main/java/net/gsantner/markor/frontend/NewFileDialog.java b/app/src/main/java/net/gsantner/markor/frontend/NewFileDialog.java
index 4e31122e8..5b4d8433e 100644
--- a/app/src/main/java/net/gsantner/markor/frontend/NewFileDialog.java
+++ b/app/src/main/java/net/gsantner/markor/frontend/NewFileDialog.java
@@ -348,6 +348,9 @@ private Pair getTemplateContent(final Spinner templateSpinner,
         final int startingIndex = t.indexOf(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN);
         t = t.replace(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN, "");
 
+        // Has no utility in a new file
+        t = t.replace(HighlightingEditor.INSERT_SELECTION_HERE_TOKEN, "");
+
         final byte[] bytes;
         if (encrypt && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
             final char[] pass = ApplicationObject.settings().getDefaultPassword();
diff --git a/app/src/main/java/net/gsantner/markor/frontend/filesearch/FileSearchEngine.java b/app/src/main/java/net/gsantner/markor/frontend/filesearch/FileSearchEngine.java
index dec1a0f2d..14575b017 100644
--- a/app/src/main/java/net/gsantner/markor/frontend/filesearch/FileSearchEngine.java
+++ b/app/src/main/java/net/gsantner/markor/frontend/filesearch/FileSearchEngine.java
@@ -233,17 +233,14 @@ private Queue currentDirectoryHandler(final File currentDir) {
 
                         final boolean isDir = f.isDirectory();
 
-                        if (!isDir && f.canRead()) {
-
-                            final int beforeContentCount = _result.size();
-                            if (_config.isSearchInContent && GsFileUtils.isTextFile(f)) {
-                                getContentMatches(f, _config.isOnlyFirstContentMatch, trimSize);
-                            }
+                        final int beforeContentCount = _result.size();
+                        if (_config.isSearchInContent && !isDir && f.canRead() && GsFileUtils.isTextFile(f)) {
+                            getContentMatches(f, _config.isOnlyFirstContentMatch, trimSize);
+                        }
 
-                            // Search name if not already included due to content
-                            if (_result.size() == beforeContentCount) {
-                                getFileIfNameMatches(f, trimSize);
-                            }
+                        // Search name if director or not already included due to content
+                        if (isDir || _result.size() == beforeContentCount) {
+                            getFileIfNameMatches(f, trimSize);
                         }
 
                         if (isDir && !isFileContainSymbolicLinks(f, currentDir)) {
@@ -251,7 +248,6 @@ private Queue currentDirectoryHandler(final File currentDir) {
                         }
                     }
 
-
                     publishProgress(_currentQueueLength + subQueue.size(), _currentSearchDepth, _result.size(), _countCheckedFiles);
                 }
             } catch (Exception ignored) {
@@ -332,11 +328,11 @@ private boolean isFileContainSymbolicLinks(File file, File expectedParentDir) {
             return true;
         }
 
-        private void getFileIfNameMatches(final File file, final int trim) {
+        private void getFileIfNameMatches(final File file, final int baseLength) {
             try {
                 final String fileName = _config.isCaseSensitiveQuery ? file.getName() : file.getName().toLowerCase();
                 if (_config.isRegexQuery ? _matcher.reset(fileName).matches() : fileName.contains(_config.query)) {
-                    _result.add(new FitFile(file.getCanonicalPath().substring(trim), file.isDirectory(), null));
+                    _result.add(new FitFile(file.getCanonicalPath().substring(baseLength), file.isDirectory(), null));
                 }
             } catch (Exception ignored) {
             }
diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/HighlightingEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/HighlightingEditor.java
index b01497717..90f9db31e 100644
--- a/app/src/main/java/net/gsantner/markor/frontend/textview/HighlightingEditor.java
+++ b/app/src/main/java/net/gsantner/markor/frontend/textview/HighlightingEditor.java
@@ -9,7 +9,6 @@
 
 import android.content.Context;
 import android.graphics.Canvas;
-import android.graphics.Color;
 import android.graphics.Paint;
 import android.graphics.Rect;
 import android.os.Build;
@@ -23,7 +22,6 @@
 import android.view.View;
 import android.view.ViewTreeObserver;
 import android.view.accessibility.AccessibilityEvent;
-import android.widget.ScrollView;
 
 import androidx.annotation.NonNull;
 import androidx.annotation.RequiresApi;
@@ -35,9 +33,6 @@
 import net.gsantner.opoc.wrapper.GsCallback;
 import net.gsantner.opoc.wrapper.GsTextWatcherAdapter;
 
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
 @SuppressWarnings("UnusedReturnValue")
 public class HighlightingEditor extends AppCompatEditText {
 
@@ -45,6 +40,7 @@ public class HighlightingEditor extends AppCompatEditText {
     final static float HIGHLIGHT_REGION_SIZE = 0.75f;        // Minimum extra screens to highlight (should be > 0.5 to cover screen)
 
     public final static String PLACE_CURSOR_HERE_TOKEN = "%%PLACE_CURSOR_HERE%%";
+    public final static String INSERT_SELECTION_HERE_TOKEN = "%%INSERT_SELECTION_HERE%%";
 
     private boolean _accessibilityEnabled = true;
     private final boolean _isSpellingRedUnderline;
@@ -52,7 +48,7 @@ public class HighlightingEditor extends AppCompatEditText {
     private boolean _isDynamicHighlightingEnabled = true;
     private Runnable _hlDebounced;        // Debounced runnable which recomputes highlighting
     private boolean _hlEnabled;           // Whether highlighting is enabled
-    private boolean _nuEnabled;           // Whether show line numbers is enabled
+    private boolean _numEnabled;          // Whether show line numbers is enabled
     private final Rect _oldHlRect;        // Rect highlighting was previously applied to
     private final Rect _hlRect;           // Current rect
     private int _hlShiftThreshold = -1;   // How much to scroll before re-apply highlight
@@ -60,17 +56,7 @@ public class HighlightingEditor extends AppCompatEditText {
     private TextWatcher _autoFormatModifier;
     private boolean _autoFormatEnabled;
     private boolean _saveInstanceState = true;
-
-    // For drawing line numbers
-    private final Paint _paint = new Paint();
-    private ScrollView _scrollView;
-    private int _x;
-    private int _maxLineNumber = 1;
-    private int _maxLineNumberWidth;
-    private int _defaultPaddingLeft;
-    private static final int LINE_NUMBERS_PADDING_LEFT = 14;
-    private static final int LINE_NUMBERS_PADDING_RIGHT = 10;
-    private final int[] _firstVisibleLine = {-1, 0}; // {line index, actual line number}
+    private final LineNumbersDrawer _lineNumbersDrawer = new LineNumbersDrawer(this);
 
 
     public HighlightingEditor(Context context, AttributeSet attrs) {
@@ -86,38 +72,17 @@ public HighlightingEditor(Context context, AttributeSet attrs) {
         }
 
         _hlEnabled = false;
-        _nuEnabled = false;
+        _numEnabled = false;
         _oldHlRect = new Rect();
         _hlRect = new Rect();
 
         addTextChangedListener(new GsTextWatcherAdapter() {
-            private final Pattern pattern = Pattern.compile("\n");
-            private Matcher matcher;
-
-            @Override
-            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
-                if (after == 0 && count > 0) {
-                    CharSequence deleted = s.subSequence(start, start + count);
-                    matcher = pattern.matcher(deleted);
-                    while (matcher.find()) {
-                        _maxLineNumber--;
-                    }
-                }
-            }
 
             @Override
             public void onTextChanged(CharSequence s, int start, int before, int count) {
                 if (_hlEnabled && _hl != null) {
                     _hl.fixup(start, before, count);
                 }
-
-                if (before == 0 && count > 0) {
-                    CharSequence added = s.subSequence(start, start + count);
-                    matcher = pattern.matcher(added);
-                    while (matcher.find()) {
-                        _maxLineNumber++;
-                    }
-                }
             }
 
             @Override
@@ -133,100 +98,22 @@ public void afterTextChanged(final Editable s) {
         observer.addOnScrollChangedListener(() -> updateHighlighting(false));
         observer.addOnGlobalLayoutListener(() -> updateHighlighting(false));
 
-        // Fix for android 12 perf issues - https://github.com/gsantner/markor/discussions/1794
+        // Fix for Android 12 perf issues - https://github.com/gsantner/markor/discussions/1794
         setEmojiCompatEnabled(false);
     }
 
-    @Override
-    protected void onFinishInflate() {
-        super.onFinishInflate();
-        _defaultPaddingLeft = getPaddingLeft();
-        _paint.setTextAlign(Paint.Align.RIGHT);
-    }
-
-    @Override
-    protected void onAttachedToWindow() {
-        super.onAttachedToWindow();
-        _scrollView = getParent() instanceof ScrollView ? (ScrollView) getParent() : (ScrollView) getParent().getParent();
-        _scrollView.setOnScrollChangeListener((v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
-            if (_firstVisibleLine[0] > -1) {
-                _firstVisibleLine[0] = -1;
-            }
-        });
-    }
-
     @Override
     public boolean onPreDraw() {
-        _paint.setTextSize(getTextSize());
+        _lineNumbersDrawer.setTextSize(getTextSize());
         return super.onPreDraw();
     }
 
     @Override
     protected void onDraw(Canvas canvas) {
         super.onDraw(canvas);
-        // If line numbers can be drawn
-        if (_nuEnabled && _maxLineNumber < (AppSettings._isDeviceGoodHardware ? 5000 : 3000)) {
-            drawLineNumbers(canvas);
-        } else if (getPaddingLeft() != _defaultPaddingLeft) {
-            _maxLineNumberWidth = 0;
-            // Reset padding without line numbers fence
-            setPadding(_defaultPaddingLeft, getPaddingTop(), getPaddingRight(), getPaddingBottom());
-        }
-    }
-
-    private void drawLineNumbers(Canvas canvas) {
-        final Editable text = getText();
-        final Layout layout = getLayout();
-        final float offsetY = getPaddingTop();
-        final int top = _scrollView.getScrollY() - 100; // Top of current visible area
-        final int bottom = _scrollView.getScrollY() + _scrollView.getHeight(); // Bottom of current visible area
-        final int width = (int) _paint.measureText(String.valueOf(_maxLineNumber));
-        if (_maxLineNumberWidth != width) {
-            _maxLineNumberWidth = width;
-            _x = LINE_NUMBERS_PADDING_LEFT + width;
-            setPadding(_x + LINE_NUMBERS_PADDING_RIGHT + 10, getPaddingTop(), getPaddingRight(), getPaddingBottom());
-        }
-
-        // Draw the vertical line
-        _paint.setColor(Color.LTGRAY);
-        canvas.drawLine(_x + LINE_NUMBERS_PADDING_RIGHT, top, _x + LINE_NUMBERS_PADDING_RIGHT, bottom, _paint);
 
-        // Draw line numbers
-        _paint.setColor(Color.GRAY);
-        canvas.drawText("1", _x, layout.getLineBounds(0, null) + offsetY, _paint);
-
-        if (text == null || text.length() == 0) {
-            return;
-        }
-
-        final int count = getLineCount();
-        int i = 1, number = 1;
-
-        if (_firstVisibleLine[0] > -1) {
-            // Set and then iterate from the first visible line
-            i = _firstVisibleLine[0];
-            number = _firstVisibleLine[1];
-        } else {
-            // Set the first visible line invalid, it needs to be updated
-            _firstVisibleLine[0] = -1;
-        }
-
-        for (int y; i < count; i++) {
-            if (text.charAt(layout.getLineStart(i) - 1) == '\n') {
-                number++;
-                y = layout.getLineBounds(i, null);
-                if (y > bottom) {
-                    break;
-                }
-                if (y > top) {
-                    if (_firstVisibleLine[0] < 0) {
-                        // Update the first visible line
-                        _firstVisibleLine[0] = i;
-                        _firstVisibleLine[1] = number - 1;
-                    }
-                    canvas.drawText(String.valueOf(number), _x, y + offsetY, _paint);
-                }
-            }
+        if (_numEnabled) {
+            _lineNumbersDrawer.draw(canvas);
         }
     }
 
@@ -245,6 +132,7 @@ private void updateHighlighting(final boolean recompute) {
 
             // Don't highlight unless shifted sufficiently or a recompute is required
             if (recompute || (visible && _hl.hasSpans() && isScrollSignificant())) {
+                _oldHlRect.set(_hlRect);
 
                 final int[] newHlRegion = hlRegion(_hlRect); // Compute this _before_ clear
                 _hl.clearDynamic();
@@ -314,16 +202,20 @@ public boolean setHighlightingEnabled(final boolean enable) {
     }
 
     public boolean getLineNumbersEnabled() {
-        return _nuEnabled;
+        return _numEnabled;
     }
 
-    public boolean setLineNumbersEnabled(final boolean enable) {
-        final boolean prev = _nuEnabled;
-
-        if (enable != _nuEnabled) {
-            _nuEnabled = enable;
+    public void setLineNumbersEnabled(final boolean enable) {
+        if (enable ^ _numEnabled) {
+            post(this::invalidate);
+        }
+        _numEnabled = enable;
+        if (_numEnabled) {
+            _lineNumbersDrawer.startLineTracking();
+        } else {
+            _lineNumbersDrawer.reset();
+            _lineNumbersDrawer.stopLineTracking();
         }
-        return prev;
     }
 
     // Region to highlight
@@ -338,6 +230,11 @@ private int[] hlRegion(final Rect rect) {
         }
     }
 
+    @Override
+    public boolean bringPointIntoView(int i) {
+        return super.bringPointIntoView(i);
+    }
+
     private int rowStart(final int y) {
         final Layout layout = getLayout();
         final int line = layout.getLineForVertical(y);
@@ -508,13 +405,30 @@ public void simulateKeyPress(int keyEvent_KEYCODE_SOMETHING) {
     public void insertOrReplaceTextOnCursor(final String newText) {
         final Editable edit = getText();
         if (edit != null && newText != null) {
-            final int newCursorPos = newText.indexOf(PLACE_CURSOR_HERE_TOKEN);
-            final String finalText = newText.replace(PLACE_CURSOR_HERE_TOKEN, "");
+
+            // TODO - should consider moving any snippet specific logic out of here
+            // Fill in any instances of selection
             final int[] sel = TextViewUtils.getSelection(this);
+            final CharSequence selected = TextViewUtils.toString(edit, sel[0], sel[1]);
+            String expanded = newText.replace(INSERT_SELECTION_HERE_TOKEN, selected);
+
+            // Determine where to place the cursor
+            final int newCursorPos = expanded.indexOf(PLACE_CURSOR_HERE_TOKEN);
+            final String finalText = expanded.replace(PLACE_CURSOR_HERE_TOKEN, "");
+
             sel[0] = Math.max(sel[0], 0);
+
+            // Needed to prevent selection of whole of inserted text after replace
+            // if we want a cursor position instead
+            if (newCursorPos >= 0) {
+                setSelection(sel[0]);
+            }
+
             withAutoFormatDisabled(() -> edit.replace(sel[0], sel[1], finalText));
+
             if (newCursorPos >= 0) {
                 setSelection(sel[0] + newCursorPos);
+                TextViewUtils.showSelection(this);
             }
         }
     }
@@ -546,4 +460,171 @@ public int setSelectionExpandWholeLines() {
     public boolean indexesValid(int... indexes) {
         return TextViewUtils.inRange(0, length(), indexes);
     }
+
+    static class LineNumbersDrawer {
+
+        private final AppCompatEditText _editor;
+        private final Paint _paint = new Paint();
+
+        private final int _defaultPaddingLeft;
+        private static final int LINE_NUMBER_PADDING_LEFT = 14;
+        private static final int LINE_NUMBER_PADDING_RIGHT = 10;
+
+        private final Rect _visibleArea = new Rect();
+        private final Rect _lineNumbersArea = new Rect();
+
+        private int _numberX;
+        private int _gutterX;
+        private int _maxNumber = 1; // to gauge gutter width
+        private int _maxNumberDigits;
+        private float _oldTextSize;
+        private final int[] _startLine = {0, 1}; // {line index, actual line number}
+
+        private final GsTextWatcherAdapter _lineTrackingWatcher = new GsTextWatcherAdapter() {
+            @Override
+            public void beforeTextChanged(CharSequence s, int start, int count, int after) {
+                _maxNumber -= TextViewUtils.countChar(s, start, start + count, '\n');
+            }
+
+            @Override
+            public void onTextChanged(CharSequence s, int start, int before, int count) {
+                _maxNumber += TextViewUtils.countChar(s, start, start + count, '\n');
+            }
+        };
+
+        public LineNumbersDrawer(final AppCompatEditText editor) {
+            _editor = editor;
+            _paint.setColor(0xFF999999);
+            _paint.setTextAlign(Paint.Align.RIGHT);
+            _defaultPaddingLeft = editor.getPaddingLeft();
+        }
+
+        public void setTextSize(final float textSize) {
+            _paint.setTextSize(textSize);
+        }
+
+        public boolean isTextSizeChanged() {
+            if (_paint.getTextSize() == _oldTextSize) {
+                return false;
+            } else {
+                _oldTextSize = _paint.getTextSize();
+                return true;
+            }
+        }
+
+        public boolean isMaxNumberDigitsChanged() {
+            final int oldDigits = _maxNumberDigits;
+
+            if (_maxNumber < 10) {
+                _maxNumberDigits = 1;
+            } else if (_maxNumber < 100) {
+                _maxNumberDigits = 2;
+            } else if (_maxNumber < 1000) {
+                _maxNumberDigits = 3;
+            } else if (_maxNumber < 10000) {
+                _maxNumberDigits = 4;
+            } else {
+                _maxNumberDigits = 5;
+            }
+            return _maxNumberDigits != oldDigits;
+        }
+
+        public boolean isOutOfLineNumbersArea() {
+            final int margin = (int) (_visibleArea.height() * 0.5f);
+            final int top = _visibleArea.top - margin;
+            final int bottom = _visibleArea.bottom + margin;
+
+            if (top < _lineNumbersArea.top || bottom > _lineNumbersArea.bottom) {
+                // Reset line numbers area
+                // height of line numbers area = (1.5 + 1 + 1.5) * height of visible area
+                _lineNumbersArea.top = top - _visibleArea.height();
+                _lineNumbersArea.bottom = bottom + _visibleArea.height();
+                return true;
+            } else {
+                return false;
+            }
+        }
+
+        public void startLineTracking() {
+            _editor.removeTextChangedListener(_lineTrackingWatcher);
+            _maxNumber = 1;
+            final CharSequence text = _editor.getText();
+            if (text != null) {
+                _maxNumber += TextViewUtils.countChar(text, 0, text.length(), '\n');
+            }
+            _editor.addTextChangedListener(_lineTrackingWatcher);
+        }
+
+        public void stopLineTracking() {
+            _editor.removeTextChangedListener(_lineTrackingWatcher);
+        }
+
+        /**
+         * Draw line numbers.
+         *
+         * @param canvas The canvas on which the line numbers will be drawn.
+         */
+        public void draw(final Canvas canvas) {
+            if (!_editor.getLocalVisibleRect(_visibleArea)) {
+                return;
+            }
+
+            final CharSequence text = _editor.getText();
+            final Layout layout = _editor.getLayout();
+            if (text == null || layout == null) {
+                return;
+            }
+
+            // If text size or the max line number of digits changed,
+            // update the variables and reset padding
+            if (isTextSizeChanged() || isMaxNumberDigitsChanged()) {
+                _numberX = LINE_NUMBER_PADDING_LEFT + (int) _paint.measureText(String.valueOf(_maxNumber));
+                _gutterX = _numberX + LINE_NUMBER_PADDING_RIGHT;
+                _editor.setPadding(_gutterX + 10, _editor.getPaddingTop(), _editor.getPaddingRight(), _editor.getPaddingBottom());
+            }
+
+            int i = _startLine[0], number = _startLine[1];
+            // If current visible area is out of current line numbers area,
+            // iterate from the first line to recalculate the start line
+            if (isOutOfLineNumbersArea()) {
+                i = 0;
+                number = 1;
+                _startLine[0] = -1;
+            }
+
+            // Draw border of the gutter
+            canvas.drawLine(_gutterX, _lineNumbersArea.top, _gutterX, _lineNumbersArea.bottom, _paint);
+
+            // Draw line numbers
+            final int count = layout.getLineCount();
+            final int offsetY = _editor.getPaddingTop();
+            for (; i < count; i++) {
+                final int start = layout.getLineStart(i);
+                if (start == 0 || text.charAt(start - 1) == '\n') {
+                    final int y = layout.getLineBaseline(i);
+                    if (y > _lineNumbersArea.bottom) {
+                        break;
+                    }
+                    if (y > _lineNumbersArea.top) {
+                        if (_startLine[0] < 0) {
+                            _startLine[0] = i;
+                            _startLine[1] = number;
+                        }
+                        canvas.drawText(String.valueOf(number), _numberX, y + offsetY, _paint);
+                    }
+                    number++;
+                }
+            }
+        }
+
+        /**
+         * Reset to the state without line numbers.
+         */
+        public void reset() {
+            if (_editor.getPaddingLeft() != _defaultPaddingLeft) {
+                _editor.setPadding(_defaultPaddingLeft, _editor.getPaddingTop(), _editor.getPaddingRight(), _editor.getPaddingBottom());
+                _maxNumberDigits = 0;
+            }
+        }
+    }
 }
\ No newline at end of file
diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/TextViewUtils.java b/app/src/main/java/net/gsantner/markor/frontend/textview/TextViewUtils.java
index 81313b926..f9c2227ae 100644
--- a/app/src/main/java/net/gsantner/markor/frontend/textview/TextViewUtils.java
+++ b/app/src/main/java/net/gsantner/markor/frontend/textview/TextViewUtils.java
@@ -214,6 +214,11 @@ public static int[] countChars(final CharSequence s, final char... chars) {
      * @return number of instances of each char in [start, end)
      */
     public static int[] countChars(final CharSequence s, int start, int end, final char... chars) {
+        // Faster specialization for the common single case
+        if (chars.length == 1) {
+            return new int[] {countChar(s, start, end, chars[0])};
+        }
+
         final int[] counts = new int[chars.length];
         start = Math.max(0, start);
         end = Math.min(end, s.length());
@@ -228,6 +233,25 @@ public static int[] countChars(final CharSequence s, int start, int end, final c
         return counts;
     }
 
+    public static int countChar(final CharSequence s, final char c) {
+        return countChar(s, 0, s.length(), c);
+    }
+
+    /**
+     * Count instances of a single char in a charsequence
+     */
+    public static int countChar(final CharSequence s, int start, int end, final char c) {
+        start = Math.max(0, start);
+        end = Math.min(end, s.length());
+        int count = 0;
+        for (int i = start; i < end; i++) {
+            if (s.charAt(i) == c) {
+                count++;
+            }
+        }
+        return count;
+    }
+
     public static boolean isNewLine(CharSequence source, int start, int end) {
         return isValidIndex(source, start, end - 1) && (source.charAt(start) == '\n' || source.charAt(end - 1) == '\n');
     }
diff --git a/app/src/main/java/net/gsantner/markor/model/AppSettings.java b/app/src/main/java/net/gsantner/markor/model/AppSettings.java
index 484e155c8..baec60b15 100644
--- a/app/src/main/java/net/gsantner/markor/model/AppSettings.java
+++ b/app/src/main/java/net/gsantner/markor/model/AppSettings.java
@@ -357,6 +357,7 @@ public void toggleFavouriteFile(File file) {
     private static final String PREF_PREFIX_VIEW_SCROLL_X = "PREF_PREFIX_VIEW_SCROLL_X";
     private static final String PREF_PREFIX_VIEW_SCROLL_Y = "PREF_PREFIX_VIEW_SCROLL_Y";
     private static final String PREF_PREFIX_TODO_DONE_NAME = "PREF_PREFIX_TODO_DONE_NAME";
+    private static final String PREF_PREFIX_LINE_NUM_STATE = "PREF_PREFIX_LINE_NUM_STATE";
 
     public void setLastTodoDoneName(final String path, final String name) {
         if (fexists(path)) {
@@ -412,6 +413,21 @@ public boolean getDocumentWrapState(final String path) {
         }
     }
 
+    public void setDocumentLineNumbersEnabled(final String path, final boolean enabled) {
+        if (fexists(path)) {
+            setBool(PREF_PREFIX_LINE_NUM_STATE + path, enabled);
+        }
+    }
+
+    public boolean getDocumentLineNumbersEnabled(final String path) {
+        final boolean _default = false;
+        if (!fexists(path)) {
+            return _default;
+        } else {
+            return getBool(PREF_PREFIX_LINE_NUM_STATE + path, _default);
+        }
+    }
+
     public void setDocumentFormat(final String path, @StringRes final int format) {
         if (fexists(path) && format != FormatRegistry.FORMAT_UNKNOWN) {
             setString(PREF_PREFIX_FILE_FORMAT + path, _context.getString(format));