From 04e57de0fc502a5b4fc313baae133da6cad7f78c Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Sat, 14 Mar 2026 02:35:31 +0100 Subject: [PATCH 01/18] fix: stabilize editor layout sizing to prevent large-file open slowdown Restore one-shot min-height initialization and avoid per-layout min-height churn, with guardrail comments to prevent reintroducing startup relayout regressions. This fixes a major speed regression introduced in commit 64ff85b6925224d9a0a3 in PR #2521 (squashed into b2f44074) that made loading of big files and opening the keyboard incredibly slow and proportional to the file size. Signed-off-by: Stephen L. Using OpenCode with ChatGPT Codex-5.3 + git-bisect --- .../activity/DocumentEditAndViewFragment.java | 21 +++++++++++++------ .../res/layout/document__fragment__edit.xml | 2 +- 2 files changed, 16 insertions(+), 7 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 66a63e17a2..741e5b7e30 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java @@ -232,14 +232,14 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { }); } - final Runnable ensureMinHeight = () -> _hlEditor.post(() -> { - final int height = _verticalScrollView.getHeight(); - if (height > 0 && height != _hlEditor.getMinHeight()) { - _hlEditor.setMinHeight(height); + // Keep this as a one-shot min-height sync. Do not use a persistent layout listener here, + // otherwise large documents trigger repeated relayout work during startup. + _verticalScrollView.post(() -> { + final int parentHeight = _verticalScrollView.getHeight(); + if (parentHeight > 0 && parentHeight != _hlEditor.getMinHeight()) { + _hlEditor.setMinHeight(parentHeight); } }); - _verticalScrollView.addOnLayoutChangeListener((v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> ensureMinHeight.run()); - _verticalScrollView.post(ensureMinHeight); } @Override @@ -258,6 +258,15 @@ protected void onFragmentFirstTimeVisible() { _hlEditor.recomputeHighlighting(); // Run before setting scroll position TextViewUtils.setSelectionAndShow(_hlEditor, startPos); + // One-shot floor for first render after content/highlighting setup. + // Do not replace with per-layout updates; they regress big-file open performance. + _editorHolder.post(() -> { + final int parentHeight = _editorHolder.getHeight(); + if (parentHeight > 0 && parentHeight != _hlEditor.getMinHeight()) { + _hlEditor.setMinHeight(parentHeight); + } + }); + // Fade in to hide initial jank _hlEditor.post(() -> _hlEditor.animate().alpha(1).setDuration(250).start()); } diff --git a/app/src/main/res/layout/document__fragment__edit.xml b/app/src/main/res/layout/document__fragment__edit.xml index 591cf96043..4853d7ae02 100644 --- a/app/src/main/res/layout/document__fragment__edit.xml +++ b/app/src/main/res/layout/document__fragment__edit.xml @@ -47,7 +47,7 @@ Date: Tue, 17 Mar 2026 00:15:40 +0100 Subject: [PATCH 02/18] fix: Refactor speed regression fix duplications into a function syncEditorMinHeightOnce() to do one-shot editor min-height sync to prevent large-file open slowdown + cover more cases as originally intended in 64ff85b6 Signed-off-by: Stephen L. --- .../activity/DocumentEditAndViewFragment.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 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 741e5b7e30..55a522ebce 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java @@ -234,12 +234,7 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { // Keep this as a one-shot min-height sync. Do not use a persistent layout listener here, // otherwise large documents trigger repeated relayout work during startup. - _verticalScrollView.post(() -> { - final int parentHeight = _verticalScrollView.getHeight(); - if (parentHeight > 0 && parentHeight != _hlEditor.getMinHeight()) { - _hlEditor.setMinHeight(parentHeight); - } - }); + syncEditorMinHeightOnce(_verticalScrollView); } @Override @@ -260,12 +255,7 @@ protected void onFragmentFirstTimeVisible() { // One-shot floor for first render after content/highlighting setup. // Do not replace with per-layout updates; they regress big-file open performance. - _editorHolder.post(() -> { - final int parentHeight = _editorHolder.getHeight(); - if (parentHeight > 0 && parentHeight != _hlEditor.getMinHeight()) { - _hlEditor.setMinHeight(parentHeight); - } - }); + syncEditorMinHeightOnce(_editorHolder); // Fade in to hide initial jank _hlEditor.post(() -> _hlEditor.animate().alpha(1).setDuration(250).start()); @@ -689,6 +679,7 @@ private void showHideActionBar() { if (viewScroll != null) { setMarginBottom(viewScroll, marginBottom); } + syncEditorMinHeightOnce(_verticalScrollView); } } } @@ -837,6 +828,18 @@ private void setMarginBottom(final View view, final int marginBottom) { } } + private void syncEditorMinHeightOnce(final View parent) { + if (parent == null) { + return; + } + parent.post(() -> { + final int parentHeight = parent.getHeight(); + if (parentHeight > 0 && parentHeight != _hlEditor.getMinHeight()) { + _hlEditor.setMinHeight(parentHeight); + } + }); + } + private void updateMenuToggleStates(final int selectedFormatActionId) { MenuItem mi; if ((mi = _fragmentMenu.findItem(R.id.action_wrap_words)) != null) { @@ -904,6 +907,7 @@ private void setWrapState(final boolean wrap) { } _hlEditor.requestLayout(); + syncEditorMinHeightOnce(_editorHolder); _hlEditor.setHighlightingEnabled(hlEnabled); _hlEditor.post(() -> { From 8650730f1aea37d4641e4b619889971b226b5aa7 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Sat, 14 Mar 2026 07:08:04 +0100 Subject: [PATCH 03/18] docs: add lrq3000 in CONTRIBUTORS.md Signed-off-by: Stephen L. --- CONTRIBUTORS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index acc0a133af..792f52f64e 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -55,3 +55,4 @@ Where: * **[Matthew White](https://github.com/mehw)**
~° Zim-Wiki link/attachment conformance * **[Markus Paintner](https://github.com/goli4thus)**
~° Added duplicate lines action * **[Janez Pavel Žebovec](https://janezpavelzebovec.net/)**
~° Slovenian translation +* **[Stephen Karl Larroque](https://github.com/lrq3000/)**
~° Bugfixes From f68822fd9180c4a9cd6836f416d7ce70278f82f9 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Sat, 14 Mar 2026 08:03:29 +0100 Subject: [PATCH 04/18] feat(editor): First draft implementation of a RecyclerView EditText editor to load and edit big files instantaneously Signed-off-by: Stephen L. --- .../activity/DocumentEditAndViewFragment.java | 203 ++++++++++++---- .../frontend/textview/RecyclerTextEditor.java | 223 ++++++++++++++++++ .../res/layout/document__fragment__edit.xml | 14 ++ app/src/main/res/values/strings.xml | 2 + 4 files changed, 391 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java 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 55a522ebce..4949534c03 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java @@ -57,6 +57,7 @@ import net.gsantner.markor.frontend.filebrowser.MarkorFileBrowserFactory; import net.gsantner.markor.frontend.textview.HighlightingEditor; import net.gsantner.markor.frontend.textview.LineNumbersView; +import net.gsantner.markor.frontend.textview.RecyclerTextEditor; import net.gsantner.markor.frontend.textview.TextViewUtils; import net.gsantner.markor.model.AppSettings; import net.gsantner.markor.model.Document; @@ -79,6 +80,7 @@ public class DocumentEditAndViewFragment extends MarkorBaseFragment implements F public static final String FRAGMENT_TAG = "DocumentEditAndViewFragment"; public static final String SAVESTATE_DOCUMENT = "DOCUMENT"; public static final String START_PREVIEW = "START_PREVIEW"; + private static final long RECYCLER_EDITOR_FILE_BYTES_THRESHOLD = 1024L * 1024L; public static float VIEW_FONT_SCALE = 100f / 15.7f; @@ -97,6 +99,7 @@ public static DocumentEditAndViewFragment newInstance(final @NonNull Document do } private HighlightingEditor _hlEditor; + private RecyclerTextEditor _recyclerEditor; private WebView _webView; private ViewStub _webViewStub; private MarkorWebViewClient _webViewClient; @@ -113,6 +116,7 @@ public static DocumentEditAndViewFragment newInstance(final @NonNull Document do private MenuItem _saveMenuItem, _undoMenuItem, _redoMenuItem; private boolean _isPreviewVisible; private boolean _nextConvertToPrintMode = false; + private boolean _isRecyclerEditorEnabled; public DocumentEditAndViewFragment() { super(); @@ -140,6 +144,7 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { final Activity activity = getActivity(); _hlEditor = view.findViewById(R.id.document__fragment__edit__highlighting_editor); + _recyclerEditor = view.findViewById(R.id.document__fragment__edit__recycler_editor); _editorHolder = view.findViewById(R.id.document__fragment__edit__editor_holder); _textActionsBar = view.findViewById(R.id.document__fragment__edit__text_actions_bar); _webViewStub = view.findViewById(R.id.document__fragment_webview_stub); @@ -159,8 +164,14 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { return; } + _isRecyclerEditorEnabled = shouldUseRecyclerEditor(); + _verticalScrollView.setVisibility(_isRecyclerEditorEnabled ? View.GONE : View.VISIBLE); + _hlEditor.setVisibility(_isRecyclerEditorEnabled ? View.GONE : View.VISIBLE); + _recyclerEditor.setVisibility(_isRecyclerEditorEnabled ? View.VISIBLE : View.GONE); + _lineNumbersView.setVisibility(_isRecyclerEditorEnabled ? View.GONE : View.VISIBLE); + _lineNumbersView.setup(_hlEditor); - _lineNumbersView.setLineNumbersEnabled(_appSettings.getDocumentLineNumbersEnabled(_document.path)); + _lineNumbersView.setLineNumbersEnabled(!_isRecyclerEditorEnabled && _appSettings.getDocumentLineNumbersEnabled(_document.path)); // Upon construction, the document format has been determined from extension etc // Here we replace it with the last saved format. @@ -191,6 +202,13 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); _hlEditor.setSaveInstanceState(false); // We will reload from disk _hlEditor.setOverScrollMode(View.OVER_SCROLL_ALWAYS); + + _recyclerEditor.setEditorLineSpacing(_appSettings.getEditorLineSpacing()); + _recyclerEditor.setEditorTextSize(_appSettings.getDocumentFontSize(_document.path)); + _recyclerEditor.setEditorTypeface(GsFontPreferenceCompat.typeface(getContext(), _appSettings.getFontFamily(), Typeface.NORMAL)); + _recyclerEditor.setBackgroundColor(_appSettings.getEditorBackgroundColor()); + _recyclerEditor.setEditorTextColor(_appSettings.getEditorForegroundColor()); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // Do not need to send contents to accessibility _hlEditor.setImportantForAccessibility(View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS); @@ -208,6 +226,7 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { updateUndoRedoIconStates(); }); _hlEditor.addTextChangedListener(GsTextWatcherAdapter.after(s -> debounced.run())); + _recyclerEditor.addTextChangedListener(debounced); // We set the keyboard to be hidden if it was hidden when we lost focus // This works well to preserve keyboard state. @@ -219,26 +238,36 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { final int hidden = WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN | adjustResize; final int shown = WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE | adjustResize; - _hlEditor.getViewTreeObserver().addOnWindowFocusChangeListener(hasFocus -> { - if (hasFocus) { - // Restore old state - _hlEditor.postDelayed(() -> window.setSoftInputMode(unchanged), 500); - } else { - final Boolean isOpen = TextViewUtils.isImeOpen(_hlEditor); - if (isOpen != null) { - window.setSoftInputMode(isOpen ? shown : hidden); + if (!_isRecyclerEditorEnabled) { + _hlEditor.getViewTreeObserver().addOnWindowFocusChangeListener(hasFocus -> { + if (hasFocus) { + _hlEditor.postDelayed(() -> window.setSoftInputMode(unchanged), 500); + } else { + final Boolean isOpen = TextViewUtils.isImeOpen(_hlEditor); + if (isOpen != null) { + window.setSoftInputMode(isOpen ? shown : hidden); + } } - } - }); + }); + } } // Keep this as a one-shot min-height sync. Do not use a persistent layout listener here, // otherwise large documents trigger repeated relayout work during startup. syncEditorMinHeightOnce(_verticalScrollView); + + if (_isRecyclerEditorEnabled) { + Toast.makeText(activity, R.string.large_file_recycler_editor_enabled, Toast.LENGTH_SHORT).show(); + } } @Override protected void onFragmentFirstTimeVisible() { + if (_isRecyclerEditorEnabled) { + _recyclerEditor.post(() -> _recyclerEditor.animate().alpha(1).setDuration(250).start()); + return; + } + final Bundle args = getArguments(); int startPos = _appSettings.getLastEditPosition(_document.path, _hlEditor.length()); if (args != null && args.containsKey(Document.EXTRA_FILE_LINE_NUMBER)) { @@ -267,7 +296,7 @@ public void onResume() { _webView.onResume(); } loadDocument(); - if (_editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getTextView() != _hlEditor) { + if (!_isRecyclerEditorEnabled && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getTextView() != _hlEditor) { _editTextUndoRedoHelper.setTextView(_hlEditor); } super.onResume(); @@ -281,8 +310,8 @@ public void onPause() { } _appSettings.addRecentFile(_document.file); _appSettings.setDocumentPreviewState(_document.path, _isPreviewVisible); - _appSettings.setLastEditPosition(_document.path, TextViewUtils.getSelection(_hlEditor)[0]); - _appSettings.setLastEditScrollY(_document.path, _verticalScrollView.getScrollY()); + _appSettings.setLastEditPosition(_document.path, _isRecyclerEditorEnabled ? 0 : TextViewUtils.getSelection(_hlEditor)[0]); + _appSettings.setLastEditScrollY(_document.path, _isRecyclerEditorEnabled ? _recyclerEditor.computeVerticalScrollOffset() : _verticalScrollView.getScrollY()); if (_document.path.equals(_appSettings.getTodoFile().getAbsolutePath())) { TodoWidgetProvider.updateTodoWidgets(); } @@ -304,22 +333,23 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { final boolean isExperimentalFeaturesEnabled = _appSettings.isExperimentalFeaturesEnabled(); final boolean isText = !_document.isBinaryFileNoTextLoading(); + final boolean supportsEditTextActions = !_isRecyclerEditorEnabled; - menu.findItem(R.id.action_undo).setVisible(isText && _appSettings.isEditorHistoryEnabled()); - menu.findItem(R.id.action_redo).setVisible(isText && _appSettings.isEditorHistoryEnabled()); + menu.findItem(R.id.action_undo).setVisible(isText && supportsEditTextActions && _appSettings.isEditorHistoryEnabled()); + menu.findItem(R.id.action_redo).setVisible(isText && supportsEditTextActions && _appSettings.isEditorHistoryEnabled()); menu.findItem(R.id.action_send_debug_log).setVisible(MainActivity.IS_DEBUG_ENABLED && !isDisplayedAtMainActivity() && !_isPreviewVisible); // Undo / Redo / Save (keep visible, but deactivated and tinted grey if not executable) - _undoMenuItem = menu.findItem(R.id.action_undo).setVisible(isText && !_isPreviewVisible); - _redoMenuItem = menu.findItem(R.id.action_redo).setVisible(isText && !_isPreviewVisible); + _undoMenuItem = menu.findItem(R.id.action_undo).setVisible(isText && supportsEditTextActions && !_isPreviewVisible); + _redoMenuItem = menu.findItem(R.id.action_redo).setVisible(isText && supportsEditTextActions && !_isPreviewVisible); _saveMenuItem = menu.findItem(R.id.action_save).setVisible(isText && !_isPreviewVisible); // Edit / Preview switch menu.findItem(R.id.action_edit).setVisible(isText && _isPreviewVisible); menu.findItem(R.id.action_preview).setVisible(isText && !_isPreviewVisible); - menu.findItem(R.id.action_search).setVisible(isText && !_isPreviewVisible); + menu.findItem(R.id.action_search).setVisible(isText && supportsEditTextActions && !_isPreviewVisible); menu.findItem(R.id.action_search_view).setVisible(isText && _isPreviewVisible); - menu.findItem(R.id.submenu_format_selection).setVisible(isText && !_isPreviewVisible); + menu.findItem(R.id.submenu_format_selection).setVisible(isText && supportsEditTextActions && !_isPreviewVisible); menu.findItem(R.id.submenu_share).setVisible(isText); menu.findItem(R.id.submenu_tools).setVisible(isText); menu.findItem(R.id.submenu_per_file_settings).setVisible(isText); @@ -339,13 +369,13 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { @Override public boolean onReceiveKeyPress(int keyCode, KeyEvent event) { - if (_format != null && _format.getActions().onReceiveKeyPress(keyCode, event)) { + if (!_isRecyclerEditorEnabled && _format != null && _format.getActions().onReceiveKeyPress(keyCode, event)) { return true; } if (event.isCtrlPressed()) { if (event.isShiftPressed() && keyCode == KeyEvent.KEYCODE_Z) { - if (_editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { + if (!_isRecyclerEditorEnabled && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::redo); updateUndoRedoIconStates(); } @@ -354,13 +384,13 @@ public boolean onReceiveKeyPress(int keyCode, KeyEvent event) { saveDocument(true); return true; } else if (keyCode == KeyEvent.KEYCODE_Y) { - if (_editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { + if (!_isRecyclerEditorEnabled && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::redo); updateUndoRedoIconStates(); } return true; } else if (keyCode == KeyEvent.KEYCODE_Z) { - if (_editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanUndo()) { + if (!_isRecyclerEditorEnabled && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanUndo()) { _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::undo); updateUndoRedoIconStates(); } @@ -375,6 +405,9 @@ public boolean onReceiveKeyPress(int keyCode, KeyEvent event) { } private void updateUndoRedoIconStates() { + if (_isRecyclerEditorEnabled) { + return; + } Drawable d; final boolean canUndo = _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanUndo(); if (_undoMenuItem != null && _undoMenuItem.isEnabled() != canUndo && (d = _undoMenuItem.setEnabled(canUndo).getIcon()) != null) { @@ -402,8 +435,12 @@ public boolean loadDocument() { return false; } - if (!_document.isContentSame(_hlEditor.getText())) { - _hlEditor.withAutoFormatDisabled(() -> _hlEditor.setTextKeepState(content)); + if (!_document.isContentSame(getCurrentText())) { + if (_isRecyclerEditorEnabled) { + _recyclerEditor.setText(content); + } else { + _hlEditor.withAutoFormatDisabled(() -> _hlEditor.setTextKeepState(content)); + } } checkTextChangeState(); @@ -427,6 +464,10 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { final int itemId = item.getItemId(); switch (itemId) { case R.id.action_undo: { + if (_isRecyclerEditorEnabled) { + Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); + return true; + } if (_editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanUndo()) { _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::undo); updateUndoRedoIconStates(); @@ -434,6 +475,10 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { return true; } case R.id.action_redo: { + if (_isRecyclerEditorEnabled) { + Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); + return true; + } if (_editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::redo); updateUndoRedoIconStates(); @@ -538,6 +583,10 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { return true; } case R.id.action_search: { + if (_isRecyclerEditorEnabled) { + Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); + return true; + } setViewModeVisibility(false); _format.getActions().onSearch(); return true; @@ -578,6 +627,10 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { return true; } case R.id.action_line_numbers: { + if (_isRecyclerEditorEnabled) { + Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); + return true; + } final boolean newState = !_lineNumbersView.isLineNumbersEnabled(); _appSettings.setDocumentLineNumbersEnabled(_document.path, newState); _lineNumbersView.setLineNumbersEnabled(newState); @@ -588,6 +641,10 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { return true; } case R.id.action_enable_highlighting: { + if (_isRecyclerEditorEnabled) { + Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); + return true; + } final boolean newState = !_hlEditor.getHighlightingEnabled(); _hlEditor.setHighlightingEnabled(newState); _appSettings.setDocumentHighlightState(_document.path, newState); @@ -595,6 +652,10 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { return true; } case R.id.action_enable_auto_format: { + if (_isRecyclerEditorEnabled) { + Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); + return true; + } final boolean newState = !_hlEditor.getAutoFormatEnabled(); _hlEditor.setAutoFormatEnabled(newState); _appSettings.setDocumentAutoFormatEnabled(_document.path, newState); @@ -619,7 +680,11 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { } _appSettings.setDocumentViewFontSize(_document.path, newSize); } else { - _hlEditor.setTextSize(TypedValue.COMPLEX_UNIT_SP, (float) newSize); + if (_isRecyclerEditorEnabled) { + _recyclerEditor.setEditorTextSize(newSize); + } else { + _hlEditor.setTextSize(TypedValue.COMPLEX_UNIT_SP, (float) newSize); + } _appSettings.setDocumentFontSize(_document.path, newSize); } }); @@ -627,7 +692,7 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { } case R.id.action_show_file_browser: { // Delay because I want menu to close before we open the file browser - _hlEditor.postDelayed(() -> MainActivity.launch(activity, _document.file, false), 250); + _verticalScrollView.postDelayed(() -> MainActivity.launch(activity, _document.file, false), 250); return true; } default: { @@ -637,7 +702,7 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { } public void checkTextChangeState() { - final boolean isTextChanged = !_document.isContentSame(_hlEditor.getText()); + final boolean isTextChanged = !_document.isContentSame(getCurrentText()); Drawable d; if (_saveMenuItem != null && _saveMenuItem.isEnabled() != isTextChanged && (d = _saveMenuItem.setEnabled(isTextChanged).getIcon()) != null) { @@ -653,13 +718,20 @@ public void applyTextFormat(final int textFormatId) { } _format = FormatRegistry.getFormat(textFormatId, activity, _document); _document.setFormat(_format.getFormatId()); - _hlEditor.setHighlighter(_format.getHighlighter()); - _hlEditor.setAutoFormatters(_format.getAutoFormatInputFilter(), _format.getAutoFormatTextWatcher()); - _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); - _format.getActions() - .setDocument(_document) - .setUiReferences(activity, _hlEditor, _webView) - .recreateActionButtons(_textActionsBar, _isPreviewVisible ? ActionButtonBase.ActionItem.DisplayMode.VIEW : ActionButtonBase.ActionItem.DisplayMode.EDIT); + if (_isRecyclerEditorEnabled) { + _hlEditor.setHighlighter(null); + _hlEditor.setAutoFormatEnabled(false); + _textActionsBar.removeAllViews(); + _format.getActions().setDocument(_document); + } else { + _hlEditor.setHighlighter(_format.getHighlighter()); + _hlEditor.setAutoFormatters(_format.getAutoFormatInputFilter(), _format.getAutoFormatTextWatcher()); + _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); + _format.getActions() + .setDocument(_document) + .setUiReferences(activity, _hlEditor, _webView) + .recreateActionButtons(_textActionsBar, _isPreviewVisible ? ActionButtonBase.ActionItem.DisplayMode.VIEW : ActionButtonBase.ActionItem.DisplayMode.EDIT); + } updateMenuToggleStates(_format.getFormatId()); showHideActionBar(); } @@ -672,10 +744,10 @@ private void showHideActionBar() { final View viewScroll = activity.findViewById(R.id.document__fragment_view_webview); if (bar != null && parent != null && _verticalScrollView != null) { - final boolean hide = _textActionsBar.getChildCount() == 0; + final boolean hide = _isRecyclerEditorEnabled || _textActionsBar.getChildCount() == 0; parent.setVisibility(hide ? View.GONE : View.VISIBLE); final int marginBottom = hide ? 0 : (int) getResources().getDimension(R.dimen.textactions_bar_height); - setMarginBottom(_verticalScrollView, marginBottom); + setMarginBottom(getCurrentEditContainer(), marginBottom); if (viewScroll != null) { setMarginBottom(viewScroll, marginBottom); } @@ -846,13 +918,16 @@ private void updateMenuToggleStates(final int selectedFormatActionId) { mi.setChecked(isWrapped()); } if ((mi = _fragmentMenu.findItem(R.id.action_enable_highlighting)) != null) { - mi.setChecked(_hlEditor.getHighlightingEnabled()); + mi.setVisible(!_isRecyclerEditorEnabled); + mi.setChecked(!_isRecyclerEditorEnabled && _hlEditor.getHighlightingEnabled()); } if ((mi = _fragmentMenu.findItem(R.id.action_line_numbers)) != null) { - mi.setChecked(_lineNumbersView.isLineNumbersEnabled()); + mi.setVisible(!_isRecyclerEditorEnabled); + mi.setChecked(!_isRecyclerEditorEnabled && _lineNumbersView.isLineNumbersEnabled()); } if ((mi = _fragmentMenu.findItem(R.id.action_enable_auto_format)) != null) { - mi.setChecked(_hlEditor.getAutoFormatEnabled()); + mi.setVisible(!_isRecyclerEditorEnabled); + mi.setChecked(!_isRecyclerEditorEnabled && _hlEditor.getAutoFormatEnabled()); } final SubMenu su; @@ -867,6 +942,9 @@ private void updateMenuToggleStates(final int selectedFormatActionId) { } private boolean isWrapped() { + if (_isRecyclerEditorEnabled) { + return _recyclerEditor.isWrapEnabled(); + } return _horizontalScrollView == null || _hlEditor.getParent() != _horizontalScrollView; } @@ -879,6 +957,10 @@ private ViewGroup.LayoutParams makeScrollViewChildParams() { } private void setWrapState(final boolean wrap) { + if (_isRecyclerEditorEnabled) { + _recyclerEditor.setWrapEnabled(wrap); + return; + } _hlEditor.setHorizontallyScrolling(!wrap); final Context context = getContext(); if (context != null && _hlEditor != null && isWrapped() != wrap) { @@ -960,7 +1042,7 @@ public boolean saveDocument(final boolean forceSaveEmpty) { } // Document is written iff writeable && content has changed - final CharSequence text = _hlEditor.getText(); + final CharSequence text = getCurrentText(); if (!_document.isContentSame(text)) { final int minLength = GsContextUtils.TEXTFILE_OVERWRITE_MIN_TEXT_LENGTH; if (!forceSaveEmpty && text != null && text.length() < minLength) { @@ -1050,20 +1132,24 @@ public void setViewModeVisibility(boolean show, final boolean animate) { } show |= _document.isBinaryFileNoTextLoading(); - _format.getActions().recreateActionButtons(_textActionsBar, show ? ActionButtonBase.ActionItem.DisplayMode.VIEW : ActionButtonBase.ActionItem.DisplayMode.EDIT); + if (!_isRecyclerEditorEnabled) { + _format.getActions().recreateActionButtons(_textActionsBar, show ? ActionButtonBase.ActionItem.DisplayMode.VIEW : ActionButtonBase.ActionItem.DisplayMode.EDIT); + } showHideActionBar(); if (show) { setupWebViewIfNeeded(activity); updateViewModeText(); - _cu.showSoftKeyboard(activity, false, _hlEditor); - _hlEditor.clearFocus(); - _hlEditor.postDelayed(() -> _cu.showSoftKeyboard(activity, false, _hlEditor), 300); - GsContextUtils.fadeInOut(_webView, _verticalScrollView, animate); + if (!_isRecyclerEditorEnabled) { + _cu.showSoftKeyboard(activity, false, _hlEditor); + _hlEditor.clearFocus(); + _hlEditor.postDelayed(() -> _cu.showSoftKeyboard(activity, false, _hlEditor), 300); + } + GsContextUtils.fadeInOut(_webView, getCurrentEditContainer(), animate); } else { if (_webView != null) { _webViewClient.setRestoreScrollY(_webView.getScrollY()); } - GsContextUtils.fadeInOut(_verticalScrollView, _webView, animate); + GsContextUtils.fadeInOut(getCurrentEditContainer(), _webView, animate); } _nextConvertToPrintMode = false; @@ -1085,14 +1171,14 @@ public void webViewJavascriptCallback(final String[] jsArgs) { @Override protected void onToolbarClicked(View v) { - if (_format != null) { + if (!_isRecyclerEditorEnabled && _format != null) { _format.getActions().runTitleClick(); } } @Override protected boolean onToolbarLongClicked(View v) { - if (isVisible() && isResumed()) { + if (!_isRecyclerEditorEnabled && isVisible() && isResumed()) { _format.getActions().runJumpBottomTopAction(_isPreviewVisible ? ActionButtonBase.ActionItem.DisplayMode.VIEW : ActionButtonBase.ActionItem.DisplayMode.EDIT); return true; } @@ -1120,7 +1206,22 @@ public HighlightingEditor getEditor() { } public String getTextString() { - final CharSequence text = _hlEditor != null ? _hlEditor.getText() : null; + final CharSequence text = getCurrentText(); return text != null ? text.toString() : ""; } + + private CharSequence getCurrentText() { + if (_isRecyclerEditorEnabled && _recyclerEditor != null) { + return _recyclerEditor.getText(); + } + return _hlEditor != null ? _hlEditor.getText() : null; + } + + private boolean shouldUseRecyclerEditor() { + return _document != null && _document.fileBytes() >= RECYCLER_EDITOR_FILE_BYTES_THRESHOLD; + } + + private View getCurrentEditContainer() { + return _isRecyclerEditorEnabled ? _recyclerEditor : _verticalScrollView; + } } diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java new file mode 100644 index 0000000000..71a03071bb --- /dev/null +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -0,0 +1,223 @@ +/*####################################################### + * + * Maintained 2017-2026 by Gregor Santner + * License of this file: Apache 2.0 + * https://www.apache.org/licenses/LICENSE-2.0 + * +#########################################################*/ +package net.gsantner.markor.frontend.textview; + +import android.content.Context; +import android.graphics.Typeface; +import android.text.Editable; +import android.text.TextWatcher; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.AppCompatEditText; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.ArrayList; + +public class RecyclerTextEditor extends RecyclerView { + private final ArrayList _lines = new ArrayList<>(); + private final LinesAdapter _adapter = new LinesAdapter(); + private final ArrayList _textChangedListeners = new ArrayList<>(); + + private boolean _trailingNewline; + private float _textSizeSp = 16f; + private float _lineSpacingMultiplier = 1f; + private int _textColor; + private int _backgroundColor; + private Typeface _typeface; + private boolean _wrapEnabled = true; + + public RecyclerTextEditor(@NonNull Context context) { + this(context, null); + } + + public RecyclerTextEditor(@NonNull Context context, @Nullable AttributeSet attrs) { + super(context, attrs); + setLayoutManager(new LinearLayoutManager(context)); + setAdapter(_adapter); + setItemAnimator(null); + _lines.add(""); + } + + public void setText(@Nullable CharSequence text) { + _lines.clear(); + _trailingNewline = false; + final String content = text != null ? text.toString() : ""; + final int len = content.length(); + int start = 0; + for (int i = 0; i < len; i++) { + if (content.charAt(i) == '\n') { + _lines.add(content.substring(start, i)); + start = i + 1; + } + } + if (start < len) { + _lines.add(content.substring(start)); + } else { + _trailingNewline = len > 0; + } + + if (_lines.isEmpty()) { + _lines.add(""); + } + _adapter.notifyDataSetChanged(); + } + + @NonNull + public CharSequence getText() { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < _lines.size(); i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(_lines.get(i)); + } + if (_trailingNewline && !_lines.isEmpty()) { + sb.append('\n'); + } + return sb; + } + + public int length() { + int total = _trailingNewline && !_lines.isEmpty() ? 1 : 0; + for (int i = 0; i < _lines.size(); i++) { + total += _lines.get(i).length(); + if (i > 0) { + total += 1; + } + } + return total; + } + + @Override + public void setBackgroundColor(int color) { + super.setBackgroundColor(color); + _backgroundColor = color; + _adapter.notifyItemRangeChanged(0, _lines.size()); + } + + public void setEditorTextColor(int color) { + _textColor = color; + _adapter.notifyItemRangeChanged(0, _lines.size()); + } + + public void setEditorTextSize(float sizeSp) { + _textSizeSp = sizeSp; + _adapter.notifyItemRangeChanged(0, _lines.size()); + } + + public void setEditorTypeface(@Nullable Typeface typeface) { + _typeface = typeface; + _adapter.notifyItemRangeChanged(0, _lines.size()); + } + + public void setEditorLineSpacing(float spacingMultiplier) { + _lineSpacingMultiplier = spacingMultiplier; + _adapter.notifyItemRangeChanged(0, _lines.size()); + } + + public void setWrapEnabled(boolean enabled) { + _wrapEnabled = enabled; + _adapter.notifyItemRangeChanged(0, _lines.size()); + } + + public boolean isWrapEnabled() { + return _wrapEnabled; + } + + public void addTextChangedListener(@Nullable Runnable listener) { + if (listener != null) { + _textChangedListeners.add(listener); + } + } + + private void notifyTextChanged() { + for (Runnable listener : _textChangedListeners) { + if (listener != null) { + listener.run(); + } + } + } + + private final class LinesAdapter extends RecyclerView.Adapter { + @NonNull + @Override + public LineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + final AppCompatEditText edit = new AppCompatEditText(parent.getContext()); + final RecyclerView.LayoutParams lp = new RecyclerView.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + edit.setLayoutParams(lp); + edit.setGravity(Gravity.START | Gravity.TOP); + edit.setPadding(0, 0, 0, 0); + edit.setHorizontallyScrolling(!_wrapEnabled); + edit.setMinHeight(1); + return new LineViewHolder(edit); + } + + @Override + public void onBindViewHolder(@NonNull LineViewHolder holder, int position) { + holder.bind(position); + } + + @Override + public int getItemCount() { + return _lines.size(); + } + } + + private final class LineViewHolder extends RecyclerView.ViewHolder { + private final AppCompatEditText _edit; + private TextWatcher _watcher; + + private LineViewHolder(@NonNull AppCompatEditText itemView) { + super(itemView); + _edit = itemView; + } + + private void bind(int position) { + if (_watcher != null) { + _edit.removeTextChangedListener(_watcher); + } + + _edit.setText(_lines.get(position)); + _edit.setTextSize(TypedValue.COMPLEX_UNIT_SP, _textSizeSp); + _edit.setTypeface(_typeface); + _edit.setTextColor(_textColor); + _edit.setBackgroundColor(_backgroundColor); + _edit.setLineSpacing(0f, _lineSpacingMultiplier); + _edit.setHorizontallyScrolling(!_wrapEnabled); + + _watcher = new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + final int pos = getBindingAdapterPosition(); + if (pos != NO_POSITION) { + _lines.set(pos, s != null ? s.toString() : ""); + notifyTextChanged(); + } + } + }; + _edit.addTextChangedListener(_watcher); + } + } +} diff --git a/app/src/main/res/layout/document__fragment__edit.xml b/app/src/main/res/layout/document__fragment__edit.xml index 4853d7ae02..7137efe264 100644 --- a/app/src/main/res/layout/document__fragment__edit.xml +++ b/app/src/main/res/layout/document__fragment__edit.xml @@ -66,8 +66,22 @@ android:scrollbars="none" android:scrollHorizontally="false" android:textCursorDrawable="@drawable/cursor_accent" /> + + + . Copy file Page Experimental features + Large-file mode enabled + Not available in large-file mode Tools Reading Read From 230f8ad4a90020cd6f18732299d17bf330ed5274 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Sat, 14 Mar 2026 18:40:44 +0100 Subject: [PATCH 05/18] feat(editor): Refactor editor architecture to enable format features in RecyclerTextEditor Implement MarkorEditor in RecyclerTextEditor and wire DocumentEditAndViewFragment to use _activeEditor for both editor types, so format features work in recycler mode while undo/redo remains non-recycler-only. Details: * Introduce and wire MarkorEditor as the common editor abstraction across normal and large-file modes. * Implement full MarkorEditor support in RecyclerTextEditor, including global selection/cursor mapping, token-aware text insertion, search/highlight state hooks, and watcher forwarding across line-based cells. * Update DocumentEditAndViewFragment to use _activeEditor for both editor types, enabling format actions and search in large-file mode while keeping undo/redo limited to non-recycler mode. This was achieved through composition + common contract (since multi-subclass inheritance is not possible in Java), so we implemented a shared interface: `MarkorEditor`. Both concrete editors implement it: * `HighlightingEditor` (classic EditText) * `RecyclerTextEditor` (large-file RecyclerView) Format behaviors already live int he format system (FormatRegistry + ActionButtonBase + format action classes) so we reused that. DocumentEditAndViewFragment now routes those format features through _activeEditor (MarkorEditor), which points to either editor depending on file size. Signed-off-by: Stephen L. Using OpenCode and Oh My Opencode with ChatGPT Codex-5.3 --- .../activity/DocumentEditAndViewFragment.java | 79 ++-- .../markor/format/ActionButtonBase.java | 41 +- .../markdown/MarkdownActionButtons.java | 19 +- .../format/todotxt/TodoTxtActionButtons.java | 20 +- .../wikitext/WikitextActionButtons.java | 9 +- .../markor/frontend/DatetimeFormatDialog.java | 8 +- .../markor/frontend/NewFileDialog.java | 8 +- .../textsearch/TextSearchFragment.java | 12 +- .../textsearch/TextSearchHandler.java | 42 +- .../frontend/textview/HighlightingEditor.java | 18 +- .../frontend/textview/MarkorEditor.java | 136 ++++++ .../frontend/textview/RecyclerTextEditor.java | 435 +++++++++++++++++- .../frontend/textview/TextViewUtils.java | 2 +- 13 files changed, 719 insertions(+), 110 deletions(-) create mode 100644 app/src/main/java/net/gsantner/markor/frontend/textview/MarkorEditor.java 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 4949534c03..f9ed5988ed 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java @@ -56,6 +56,7 @@ import net.gsantner.markor.frontend.MarkorDialogFactory; import net.gsantner.markor.frontend.filebrowser.MarkorFileBrowserFactory; import net.gsantner.markor.frontend.textview.HighlightingEditor; +import net.gsantner.markor.frontend.textview.MarkorEditor; import net.gsantner.markor.frontend.textview.LineNumbersView; import net.gsantner.markor.frontend.textview.RecyclerTextEditor; import net.gsantner.markor.frontend.textview.TextViewUtils; @@ -100,6 +101,7 @@ public static DocumentEditAndViewFragment newInstance(final @NonNull Document do private HighlightingEditor _hlEditor; private RecyclerTextEditor _recyclerEditor; + private MarkorEditor _activeEditor; private WebView _webView; private ViewStub _webViewStub; private MarkorWebViewClient _webViewClient; @@ -165,6 +167,7 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { } _isRecyclerEditorEnabled = shouldUseRecyclerEditor(); + _activeEditor = _isRecyclerEditorEnabled ? _recyclerEditor : _hlEditor; _verticalScrollView.setVisibility(_isRecyclerEditorEnabled ? View.GONE : View.VISIBLE); _hlEditor.setVisibility(_isRecyclerEditorEnabled ? View.GONE : View.VISIBLE); _recyclerEditor.setVisibility(_isRecyclerEditorEnabled ? View.VISIBLE : View.GONE); @@ -333,23 +336,23 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { final boolean isExperimentalFeaturesEnabled = _appSettings.isExperimentalFeaturesEnabled(); final boolean isText = !_document.isBinaryFileNoTextLoading(); - final boolean supportsEditTextActions = !_isRecyclerEditorEnabled; + final boolean supportsUndoRedo = !_isRecyclerEditorEnabled; - menu.findItem(R.id.action_undo).setVisible(isText && supportsEditTextActions && _appSettings.isEditorHistoryEnabled()); - menu.findItem(R.id.action_redo).setVisible(isText && supportsEditTextActions && _appSettings.isEditorHistoryEnabled()); + menu.findItem(R.id.action_undo).setVisible(isText && supportsUndoRedo && _appSettings.isEditorHistoryEnabled()); + menu.findItem(R.id.action_redo).setVisible(isText && supportsUndoRedo && _appSettings.isEditorHistoryEnabled()); menu.findItem(R.id.action_send_debug_log).setVisible(MainActivity.IS_DEBUG_ENABLED && !isDisplayedAtMainActivity() && !_isPreviewVisible); // Undo / Redo / Save (keep visible, but deactivated and tinted grey if not executable) - _undoMenuItem = menu.findItem(R.id.action_undo).setVisible(isText && supportsEditTextActions && !_isPreviewVisible); - _redoMenuItem = menu.findItem(R.id.action_redo).setVisible(isText && supportsEditTextActions && !_isPreviewVisible); + _undoMenuItem = menu.findItem(R.id.action_undo).setVisible(isText && supportsUndoRedo && !_isPreviewVisible); + _redoMenuItem = menu.findItem(R.id.action_redo).setVisible(isText && supportsUndoRedo && !_isPreviewVisible); _saveMenuItem = menu.findItem(R.id.action_save).setVisible(isText && !_isPreviewVisible); // Edit / Preview switch menu.findItem(R.id.action_edit).setVisible(isText && _isPreviewVisible); menu.findItem(R.id.action_preview).setVisible(isText && !_isPreviewVisible); - menu.findItem(R.id.action_search).setVisible(isText && supportsEditTextActions && !_isPreviewVisible); + menu.findItem(R.id.action_search).setVisible(isText && !_isPreviewVisible); menu.findItem(R.id.action_search_view).setVisible(isText && _isPreviewVisible); - menu.findItem(R.id.submenu_format_selection).setVisible(isText && supportsEditTextActions && !_isPreviewVisible); + menu.findItem(R.id.submenu_format_selection).setVisible(isText && !_isPreviewVisible); menu.findItem(R.id.submenu_share).setVisible(isText); menu.findItem(R.id.submenu_tools).setVisible(isText); menu.findItem(R.id.submenu_per_file_settings).setVisible(isText); @@ -369,14 +372,14 @@ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { @Override public boolean onReceiveKeyPress(int keyCode, KeyEvent event) { - if (!_isRecyclerEditorEnabled && _format != null && _format.getActions().onReceiveKeyPress(keyCode, event)) { + if (_activeEditor != null && _format != null && _format.getActions().onReceiveKeyPress(keyCode, event)) { return true; } if (event.isCtrlPressed()) { if (event.isShiftPressed() && keyCode == KeyEvent.KEYCODE_Z) { - if (!_isRecyclerEditorEnabled && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { - _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::redo); + if (_activeEditor != null && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { + _activeEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::redo); updateUndoRedoIconStates(); } return true; @@ -384,14 +387,14 @@ public boolean onReceiveKeyPress(int keyCode, KeyEvent event) { saveDocument(true); return true; } else if (keyCode == KeyEvent.KEYCODE_Y) { - if (!_isRecyclerEditorEnabled && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { - _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::redo); + if (_activeEditor != null && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { + _activeEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::redo); updateUndoRedoIconStates(); } return true; } else if (keyCode == KeyEvent.KEYCODE_Z) { - if (!_isRecyclerEditorEnabled && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanUndo()) { - _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::undo); + if (_activeEditor != null && _editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanUndo()) { + _activeEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::undo); updateUndoRedoIconStates(); } return true; @@ -464,23 +467,23 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { final int itemId = item.getItemId(); switch (itemId) { case R.id.action_undo: { - if (_isRecyclerEditorEnabled) { + if (_activeEditor == null) { Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); return true; } if (_editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanUndo()) { - _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::undo); + _activeEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::undo); updateUndoRedoIconStates(); } return true; } case R.id.action_redo: { - if (_isRecyclerEditorEnabled) { + if (_activeEditor == null) { Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); return true; } if (_editTextUndoRedoHelper != null && _editTextUndoRedoHelper.getCanRedo()) { - _hlEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::redo); + _activeEditor.withAutoFormatDisabled(_editTextUndoRedoHelper::redo); updateUndoRedoIconStates(); } return true; @@ -641,23 +644,23 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { return true; } case R.id.action_enable_highlighting: { - if (_isRecyclerEditorEnabled) { + if (_activeEditor == null) { Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); return true; } - final boolean newState = !_hlEditor.getHighlightingEnabled(); - _hlEditor.setHighlightingEnabled(newState); + final boolean newState = !_activeEditor.getHighlightingEnabled(); + _activeEditor.setHighlightingEnabled(newState); _appSettings.setDocumentHighlightState(_document.path, newState); updateMenuToggleStates(0); return true; } case R.id.action_enable_auto_format: { - if (_isRecyclerEditorEnabled) { + if (_activeEditor == null) { Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); return true; } - final boolean newState = !_hlEditor.getAutoFormatEnabled(); - _hlEditor.setAutoFormatEnabled(newState); + final boolean newState = !_activeEditor.getAutoFormatEnabled(); + _activeEditor.setAutoFormatEnabled(newState); _appSettings.setDocumentAutoFormatEnabled(_document.path, newState); updateMenuToggleStates(0); return true; @@ -718,19 +721,17 @@ public void applyTextFormat(final int textFormatId) { } _format = FormatRegistry.getFormat(textFormatId, activity, _document); _document.setFormat(_format.getFormatId()); - if (_isRecyclerEditorEnabled) { - _hlEditor.setHighlighter(null); - _hlEditor.setAutoFormatEnabled(false); - _textActionsBar.removeAllViews(); - _format.getActions().setDocument(_document); - } else { - _hlEditor.setHighlighter(_format.getHighlighter()); - _hlEditor.setAutoFormatters(_format.getAutoFormatInputFilter(), _format.getAutoFormatTextWatcher()); - _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); + if (_activeEditor != null) { + _activeEditor.setHighlighter(_format.getHighlighter()); + _activeEditor.setAutoFormatters(_format.getAutoFormatInputFilter(), _format.getAutoFormatTextWatcher()); + _activeEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); _format.getActions() .setDocument(_document) - .setUiReferences(activity, _hlEditor, _webView) + .setUiReferences(activity, _activeEditor, _webView) .recreateActionButtons(_textActionsBar, _isPreviewVisible ? ActionButtonBase.ActionItem.DisplayMode.VIEW : ActionButtonBase.ActionItem.DisplayMode.EDIT); + } else { + _textActionsBar.removeAllViews(); + _format.getActions().setDocument(_document); } updateMenuToggleStates(_format.getFormatId()); showHideActionBar(); @@ -744,7 +745,7 @@ private void showHideActionBar() { final View viewScroll = activity.findViewById(R.id.document__fragment_view_webview); if (bar != null && parent != null && _verticalScrollView != null) { - final boolean hide = _isRecyclerEditorEnabled || _textActionsBar.getChildCount() == 0; + final boolean hide = _textActionsBar.getChildCount() == 0; parent.setVisibility(hide ? View.GONE : View.VISIBLE); final int marginBottom = hide ? 0 : (int) getResources().getDimension(R.dimen.textactions_bar_height); setMarginBottom(getCurrentEditContainer(), marginBottom); @@ -918,16 +919,16 @@ private void updateMenuToggleStates(final int selectedFormatActionId) { mi.setChecked(isWrapped()); } if ((mi = _fragmentMenu.findItem(R.id.action_enable_highlighting)) != null) { - mi.setVisible(!_isRecyclerEditorEnabled); - mi.setChecked(!_isRecyclerEditorEnabled && _hlEditor.getHighlightingEnabled()); + mi.setVisible(_activeEditor != null); + mi.setChecked(_activeEditor != null && _activeEditor.getHighlightingEnabled()); } if ((mi = _fragmentMenu.findItem(R.id.action_line_numbers)) != null) { mi.setVisible(!_isRecyclerEditorEnabled); mi.setChecked(!_isRecyclerEditorEnabled && _lineNumbersView.isLineNumbersEnabled()); } if ((mi = _fragmentMenu.findItem(R.id.action_enable_auto_format)) != null) { - mi.setVisible(!_isRecyclerEditorEnabled); - mi.setChecked(!_isRecyclerEditorEnabled && _hlEditor.getAutoFormatEnabled()); + mi.setVisible(_activeEditor != null); + mi.setChecked(_activeEditor != null && _activeEditor.getAutoFormatEnabled()); } final SubMenu su; diff --git a/app/src/main/java/net/gsantner/markor/format/ActionButtonBase.java b/app/src/main/java/net/gsantner/markor/format/ActionButtonBase.java index a0d51a63aa..0e6f68e967 100644 --- a/app/src/main/java/net/gsantner/markor/format/ActionButtonBase.java +++ b/app/src/main/java/net/gsantner/markor/format/ActionButtonBase.java @@ -27,6 +27,7 @@ import android.webkit.WebView; import android.widget.EditText; import android.widget.ImageView; +import android.widget.TextView; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; @@ -42,7 +43,7 @@ import net.gsantner.markor.frontend.MarkorDialogFactory; import net.gsantner.markor.frontend.MarkorDialogFactory.Heading; import net.gsantner.markor.frontend.textsearch.TextSearchFragment; -import net.gsantner.markor.frontend.textview.HighlightingEditor; +import net.gsantner.markor.frontend.textview.MarkorEditor; import net.gsantner.markor.frontend.textview.TextViewUtils; import net.gsantner.markor.model.AppSettings; import net.gsantner.markor.model.Document; @@ -74,7 +75,7 @@ public abstract class ActionButtonBase { private final int _buttonHorizontalMargin; private String _lastSnip; - protected HighlightingEditor _hlEditor; + protected MarkorEditor _hlEditor; protected WebView _webView; protected Document _document; protected AppSettings _appSettings; @@ -458,7 +459,7 @@ public void runRegexReplaceAction(final String pattern, final String replace) { runRegexReplaceAction(Collections.singletonList(new ReplacePattern(pattern, replace))); } - public static void runRegexReplaceAction(final EditText editor, final ReplacePattern... patterns) { + public static void runRegexReplaceAction(final MarkorEditor editor, final ReplacePattern... patterns) { runRegexReplaceAction(editor, Arrays.asList(patterns)); } @@ -468,9 +469,17 @@ public static void runRegexReplaceAction(final EditText editor, final ReplacePat * * @param patterns An array of ReplacePattern */ + public static void runRegexReplaceAction(final MarkorEditor editor, final List patterns) { + editor.withAutoFormatDisabled(() -> runRegexReplaceAction(editor.getText(), patterns)); + } + + public static void runRegexReplaceAction(final EditText editor, final ReplacePattern... patterns) { + runRegexReplaceAction(editor, Arrays.asList(patterns)); + } + public static void runRegexReplaceAction(final EditText editor, final List patterns) { - if (editor instanceof HighlightingEditor) { - ((HighlightingEditor) editor).withAutoFormatDisabled(() -> runRegexReplaceAction(editor.getText(), patterns)); + if (editor instanceof MarkorEditor) { + runRegexReplaceAction((MarkorEditor) editor, patterns); } else { runRegexReplaceAction(editor.getText(), patterns); } @@ -589,7 +598,7 @@ protected void runSurroundAction(final String open, final String close, final bo _hlEditor.setSelection(ss + ol, se + ol); } - public ActionButtonBase setUiReferences(@Nullable final Activity activity, @Nullable final HighlightingEditor hlEditor, @Nullable final WebView webview) { + public ActionButtonBase setUiReferences(@Nullable final Activity activity, @Nullable final MarkorEditor hlEditor, @Nullable final WebView webview) { _activity = activity; _hlEditor = hlEditor; _webView = webview; @@ -657,7 +666,7 @@ protected final boolean runCommonAction(final @StringRes int action) { return true; } case R.string.abid_common_accordion: { - _hlEditor.insertOrReplaceTextOnCursor("
" + rstr(R.string.expand_collapse) + "\n" + HighlightingEditor.PLACE_CURSOR_HERE_TOKEN + "\n\n
"); + _hlEditor.insertOrReplaceTextOnCursor("
" + rstr(R.string.expand_collapse) + "\n" + MarkorEditor.PLACE_CURSOR_HERE_TOKEN + "\n\n
"); return true; } case R.string.abid_common_insert_audio: { @@ -690,18 +699,18 @@ protected final boolean runCommonAction(final @StringRes int action) { } case R.string.abid_common_insert_snippet: { MarkorDialogFactory.showInsertSnippetDialog(_activity, (snip) -> { - _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(snip, _document.title, TextViewUtils.getSelectedText(_hlEditor))); + _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(snip, _document.title, TextViewUtils.getSelectedText(_hlEditor.getText()))); _lastSnip = snip; }); return true; } case R.string.abid_common_open_link_browser: { - final int sel = TextViewUtils.getSelection(_hlEditor)[0]; + final int sel = TextViewUtils.getSelection(_hlEditor.getText())[0]; if (sel < 0) { return true; } - final String line = TextViewUtils.getSelectedLines(_hlEditor, sel); + final String line = TextViewUtils.getSelectedLines(_hlEditor.getText(), sel); final int cursor = sel - TextViewUtils.getLineStart(_hlEditor.getText(), sel); // First try to pull a resource @@ -736,7 +745,7 @@ protected final boolean runCommonAction(final @StringRes int action) { } case R.string.abid_common_new_line_below: { // Go to end of line, works with wrapped lines too - final int sel = TextViewUtils.getSelection(_hlEditor)[1]; + final int sel = TextViewUtils.getSelection(_hlEditor.getText())[1]; if (sel > 0) { _hlEditor.setSelection(TextViewUtils.getLineEnd(text, sel)); _hlEditor.simulateKeyPress(KeyEvent.KEYCODE_ENTER); @@ -818,12 +827,14 @@ protected final boolean runCommonLongPressAction(@StringRes int action) { } case R.string.abid_common_move_text_one_line_up: case R.string.abid_common_move_text_one_line_down: { - TextViewUtils.showSelection(_hlEditor); + if (_hlEditor.getView() instanceof TextView) { + TextViewUtils.showSelection((TextView) _hlEditor.getView()); + } return true; } case R.string.abid_common_insert_snippet: { if (!TextUtils.isEmpty(_lastSnip)) { - _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(_lastSnip, _document.title, TextViewUtils.getSelectedText(_hlEditor))); + _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(_lastSnip, _document.title, TextViewUtils.getSelectedText(_hlEditor.getText()))); } return true; } @@ -886,7 +897,7 @@ public ActionItem setRepeatable(boolean repeatable) { } } - public static void moveLineSelectionBy1(final HighlightingEditor hlEditor, final boolean isUp) { + public static void moveLineSelectionBy1(final MarkorEditor hlEditor, final boolean isUp) { final Editable text = hlEditor.getText(); final int[] sel = TextViewUtils.getSelection(text); if (text == null || sel[0] < 0) { @@ -918,7 +929,7 @@ public static void moveLineSelectionBy1(final HighlightingEditor hlEditor, final } } - public static void duplicateLineSelection(final HighlightingEditor hlEditor) { + public static void duplicateLineSelection(final MarkorEditor hlEditor) { // Duplication is performed downwards, selection is moving alongside it and // cursor is preserved regarding column position (helpful for editing the // newly created line at the selected position right away). diff --git a/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownActionButtons.java b/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownActionButtons.java index 6d45c828bd..cb938de38f 100644 --- a/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownActionButtons.java +++ b/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownActionButtons.java @@ -11,6 +11,7 @@ import android.content.Context; import android.text.Editable; import android.view.KeyEvent; +import android.widget.EditText; import androidx.annotation.NonNull; import androidx.annotation.StringRes; @@ -165,12 +166,12 @@ public boolean onActionClick(final @StringRes int action) { * @param delim - Delimiter to surround text with */ private void runLineSurroundAction(final Pattern pattern, final String delim) { - final int[] sel = TextViewUtils.getSelection(_hlEditor); + final int[] sel = TextViewUtils.getSelection(_hlEditor.getText()); if (sel[0] < 0) { return; } - final String lineBefore = sel[0] == sel[1] ? TextViewUtils.getSelectedLines(_hlEditor, sel[0]) : null; + final String lineBefore = sel[0] == sel[1] ? TextViewUtils.getSelectedLines(_hlEditor.getText(), sel[0]) : null; runRegexReplaceAction( new ReplacePattern(pattern, "$1$2$4$6"), new ReplacePattern(LINE_NONE, "$1$2" + delim + "$3" + delim + "$4") @@ -178,7 +179,7 @@ private void runLineSurroundAction(final Pattern pattern, final String delim) { // This logic sets the cursor to the inside of the delimiters if the delimiters were empty if (lineBefore != null) { - final String lineAfter = TextViewUtils.getSelectedLines(_hlEditor, sel[0]); + final String lineAfter = TextViewUtils.getSelectedLines(_hlEditor.getText(), sel[0]); final String pair = delim + delim; if (lineAfter.length() - lineBefore.length() == pair.length() && lineAfter.trim().endsWith(pair)) { final Editable text = _hlEditor.getText(); @@ -216,7 +217,11 @@ public boolean onActionLongClick(final @StringRes int action) { case R.string.abid_common_checkbox_list: { MarkorDialogFactory.showDocumentChecklistDialog( getActivity(), _hlEditor.getText(), CHECKED_LIST_LINE, 4, "xX", " ", - pos -> TextViewUtils.setSelectionAndShow(_hlEditor, pos)); + pos -> { + if (_hlEditor.getView() instanceof EditText) { + TextViewUtils.setSelectionAndShow((EditText) _hlEditor.getView(), pos); + } + }); return true; } default: { @@ -264,7 +269,7 @@ public static Link extract(final CharSequence text, final int pos) { } private boolean followLinkUnderCursor() { - final int sel = TextViewUtils.getSelection(_hlEditor)[0]; + final int sel = TextViewUtils.getSelection(_hlEditor.getText())[0]; if (sel < 0) { return false; } @@ -291,7 +296,7 @@ private void insertTableRow(int cols, boolean isHeaderEnabled) { _hlEditor.requestFocus(); // Append if current line empty - final int[] sel = TextViewUtils.getLineSelection(_hlEditor); + final int[] sel = TextViewUtils.getLineSelection(_hlEditor.getText()); if (sel[0] != -1 && sel[0] == sel[1]) { sb.append("\n"); } @@ -321,7 +326,7 @@ private void insertTableRow(int cols, boolean isHeaderEnabled) { @Override public boolean runTitleClick() { final Matcher m = MarkdownReplacePatternGenerator.PREFIX_ATX_HEADING.matcher(""); - MarkorDialogFactory.showHeadlineDialog(getActivity(), _hlEditor, _webView, _headlineDialogState, (text, start, end) -> { + MarkorDialogFactory.showHeadlineDialog(getActivity(), (EditText) _hlEditor.getView(), _webView, _headlineDialogState, (text, start, end) -> { if (m.reset(text.subSequence(start, end)).find()) { return m.end(2) - m.start(2) - 1; } diff --git a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtActionButtons.java b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtActionButtons.java index 4c51992032..076f83a0ca 100644 --- a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtActionButtons.java +++ b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtActionButtons.java @@ -13,6 +13,8 @@ import android.content.DialogInterface; import android.os.Bundle; import android.text.Editable; +import android.widget.EditText; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.StringRes; @@ -74,7 +76,7 @@ protected int getFormatActionsKey() { @SuppressLint("NonConstantResourceId") @Override public boolean onActionClick(final @StringRes int action) { - final List selTasks = TodoTxtTask.getSelectedTasks(_hlEditor); + final List selTasks = TodoTxtTask.getSelectedTasks((TextView) _hlEditor.getView()); switch (action) { case R.string.abid_todotxt_toggle_done: { @@ -141,11 +143,11 @@ public boolean onActionLongClick(final @StringRes int action) { switch (action) { case R.string.abid_todotxt_add_context: { - MarkorDialogFactory.showSttKeySearchDialog(getActivity(), _hlEditor, R.string.browse_by_context, true, true, TodoTxtFilter.TYPE.CONTEXT); + MarkorDialogFactory.showSttKeySearchDialog(getActivity(), (EditText) _hlEditor.getView(), R.string.browse_by_context, true, true, TodoTxtFilter.TYPE.CONTEXT); return true; } case R.string.abid_todotxt_add_project: { - MarkorDialogFactory.showSttKeySearchDialog(getActivity(), _hlEditor, R.string.browse_by_project, true, true, TodoTxtFilter.TYPE.PROJECT); + MarkorDialogFactory.showSttKeySearchDialog(getActivity(), (EditText) _hlEditor.getView(), R.string.browse_by_project, true, true, TodoTxtFilter.TYPE.PROJECT); return true; } case R.string.abid_todotxt_sort_todo: { @@ -159,7 +161,7 @@ public boolean onActionLongClick(final @StringRes int action) { } case R.string.abid_todotxt_priority: { final Editable text = _hlEditor.getText(); - final int[] sel = TextViewUtils.getSelection(_hlEditor); + final int[] sel = TextViewUtils.getSelection(_hlEditor.getText()); final int lineStart = TextViewUtils.getLineStart(text, sel[0]); final int lineEnd = TextViewUtils.getLineEnd(text, sel[1]); final List tasks = TodoTxtTask.getTasks(text, new int[]{sel[0], sel[1]}); @@ -245,7 +247,7 @@ public void archiveDoneTasks() { if (new Document(doneFile).saveContent(getActivity(), doneContents.toString())) { final String tasksString = TodoTxtTask.tasksToString(keep); _hlEditor.setText(tasksString); - TextViewUtils.setSelectionFromOffsets(_hlEditor, offsets); + TextViewUtils.setSelectionFromOffsets((TextView) _hlEditor.getView(), offsets); } } _appSettings.setLastTodoDoneName(_document.path, doneName); @@ -254,13 +256,13 @@ public void archiveDoneTasks() { @Override public boolean runTitleClick() { - MarkorDialogFactory.showSttFilteringDialog(getActivity(), _hlEditor); + MarkorDialogFactory.showSttFilteringDialog(getActivity(), (EditText) _hlEditor.getView()); return true; } @Override public boolean onSearch() { - MarkorDialogFactory.showSttSearchDialog(getActivity(), _hlEditor); + MarkorDialogFactory.showSttSearchDialog(getActivity(), (EditText) _hlEditor.getView()); return true; } @@ -273,7 +275,7 @@ private void addRemoveItems( final TodoTxtTask additional = new TodoTxtTask(_appSettings.getTodotxtAdditionalContextsAndProjects()); all.addAll(keyGetter.callback(Collections.singletonList(additional))); - final Set current = new HashSet<>(keyGetter.callback(TodoTxtTask.getSelectedTasks(_hlEditor))); + final Set current = new HashSet<>(keyGetter.callback(TodoTxtTask.getSelectedTasks((TextView) _hlEditor.getView()))); final boolean append = _appSettings.isTodoAppendProConOnEndEnabled(); @@ -396,7 +398,7 @@ private void setDate() { private void setDueDate(final int offset) { - final String dueString = TodoTxtTask.getSelectedTasks(_hlEditor).get(0).getDueDate(); + final String dueString = TodoTxtTask.getSelectedTasks((TextView) _hlEditor.getView()).get(0).getDueDate(); Calendar initDate = parseDateString(dueString, Calendar.getInstance()); initDate.add(Calendar.DAY_OF_MONTH, (dueString == null || dueString.isEmpty()) ? offset : 0); diff --git a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java index b2eb4f92af..ecb77dc445 100644 --- a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java +++ b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java @@ -10,6 +10,7 @@ import android.content.Context; import android.os.Build; import android.view.KeyEvent; +import android.widget.EditText; import androidx.annotation.NonNull; import androidx.annotation.StringRes; @@ -196,7 +197,7 @@ private void openLink() { } private String tryExtractWikitextLink() { - int cursorPos = TextViewUtils.getSelection(_hlEditor)[0]; + int cursorPos = TextViewUtils.getSelection(_hlEditor.getText())[0]; CharSequence text = _hlEditor.getText(); int lineStart = TextViewUtils.getLineStart(text, cursorPos); int lineEnd = TextViewUtils.getLineEnd(text, cursorPos); @@ -216,11 +217,11 @@ private void toggleHeading(int headingLevel) { final CharSequence text = _hlEditor.getText(); runRegexReplaceAction(WikitextReplacePatternGenerator.setOrUnsetHeadingWithLevel(headingLevel)); - final int[] lineSelection = TextViewUtils.getLineSelection(_hlEditor); + final int[] lineSelection = TextViewUtils.getLineSelection(_hlEditor.getText()); Matcher m = WikitextSyntaxHighlighter.HEADING.matcher(text.subSequence(lineSelection[0], lineSelection[1])); if (m.find()) { final int afterHeadingTextOffset = m.end(3); - final int lineStart = TextViewUtils.getLineStart(text, TextViewUtils.getSelection(_hlEditor)[0]); + final int lineStart = TextViewUtils.getLineStart(text, TextViewUtils.getSelection(_hlEditor.getText())[0]); _hlEditor.setSelection(lineStart + afterHeadingTextOffset); } } @@ -258,7 +259,7 @@ public static String createWikitextHeaderAndTitleContents(String fileNameWithout @Override public boolean runTitleClick() { final Matcher m = WikitextSyntaxHighlighter.HEADING.matcher(""); - MarkorDialogFactory.showHeadlineDialog(getActivity(), _hlEditor, _webView, _headlineDialogState, (text, start, end) -> { + MarkorDialogFactory.showHeadlineDialog(getActivity(), (EditText) _hlEditor.getView(), _webView, _headlineDialogState, (text, start, end) -> { if (m.reset(text.subSequence(start, end)).find()) { return 7 - (m.end(2) - m.start(2)); } diff --git a/app/src/main/java/net/gsantner/markor/frontend/DatetimeFormatDialog.java b/app/src/main/java/net/gsantner/markor/frontend/DatetimeFormatDialog.java index 6ed594e989..453ba6c3a9 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/DatetimeFormatDialog.java +++ b/app/src/main/java/net/gsantner/markor/frontend/DatetimeFormatDialog.java @@ -28,7 +28,7 @@ import androidx.core.os.ConfigurationCompat; import net.gsantner.markor.R; -import net.gsantner.markor.frontend.textview.HighlightingEditor; +import net.gsantner.markor.frontend.textview.MarkorEditor; import net.gsantner.markor.model.AppSettings; import net.gsantner.opoc.model.GsSharedPreferencesPropertyBackend; import net.gsantner.opoc.util.GsContextUtils; @@ -78,10 +78,10 @@ public class DatetimeFormatDialog { /** * @param activity {@link Activity} from which is {@link DatetimeFormatDialog} called - * @param hlEditor {@link HighlightingEditor} which 'll add selected result to cursor position + * @param hlEditor {@link MarkorEditor} which 'll add selected result to cursor position */ @SuppressLint({"ClickableViewAccessibility", "SetTextI18n, InflateParams"}) - public static void showDatetimeFormatDialog(final Activity activity, final HighlightingEditor hlEditor) { + public static void showDatetimeFormatDialog(final Activity activity, final MarkorEditor hlEditor) { final AlertDialog.Builder builder = new AlertDialog.Builder(activity, R.style.Theme_AppCompat_DayNight_Dialog_Rounded); final View viewRoot = activity.getLayoutInflater().inflate(R.layout.time_format_dialog, null); @@ -352,4 +352,4 @@ public static String getMostRecentDate(final Context context) { return ""; } } -} \ No newline at end of file +} 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 cc3b390051..0d272b7e6e 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/NewFileDialog.java +++ b/app/src/main/java/net/gsantner/markor/frontend/NewFileDialog.java @@ -38,7 +38,7 @@ import net.gsantner.markor.R; import net.gsantner.markor.format.FormatRegistry; -import net.gsantner.markor.frontend.textview.HighlightingEditor; +import net.gsantner.markor.frontend.textview.MarkorEditor; import net.gsantner.markor.frontend.textview.TextViewUtils; import net.gsantner.markor.model.AppSettings; import net.gsantner.markor.model.Document; @@ -393,11 +393,11 @@ public void setCallback(final GsCallback.a1 callback) { private Pair getTemplateContent(final String template, final String name) { String text = TextViewUtils.interpolateSnippet(template, name, ""); - final int startingIndex = text.indexOf(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN); - text = text.replaceAll(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN, ""); + final int startingIndex = text.indexOf(MarkorEditor.PLACE_CURSOR_HERE_TOKEN); + text = text.replaceAll(MarkorEditor.PLACE_CURSOR_HERE_TOKEN, ""); // Has no utility in a new file - text = text.replaceAll(HighlightingEditor.INSERT_SELECTION_HERE_TOKEN, ""); + text = text.replaceAll(MarkorEditor.INSERT_SELECTION_HERE_TOKEN, ""); return Pair.create(text, startingIndex); } diff --git a/app/src/main/java/net/gsantner/markor/frontend/textsearch/TextSearchFragment.java b/app/src/main/java/net/gsantner/markor/frontend/textsearch/TextSearchFragment.java index 6731e08e9f..e3a8f51b8e 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textsearch/TextSearchFragment.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textsearch/TextSearchFragment.java @@ -32,7 +32,7 @@ import net.gsantner.markor.R; import net.gsantner.markor.frontend.MarkorDialogFactory; -import net.gsantner.markor.frontend.textview.HighlightingEditor; +import net.gsantner.markor.frontend.textview.MarkorEditor; import net.gsantner.markor.frontend.textview.TextViewUtils; import java.util.regex.Matcher; @@ -41,7 +41,7 @@ public class TextSearchFragment extends Fragment { private int containerViewId; private FragmentActivity activity; - private HighlightingEditor editText; + private MarkorEditor editText; private EditText searchEditText; private EditText replaceEditText; @@ -51,7 +51,7 @@ public class TextSearchFragment extends Fragment { private boolean initialized; - public static TextSearchFragment newInstance(@IdRes int containerViewId, FragmentActivity activity, HighlightingEditor editText) { + public static TextSearchFragment newInstance(@IdRes int containerViewId, FragmentActivity activity, MarkorEditor editText) { FragmentManager fragmentManager = activity.getSupportFragmentManager(); Fragment fragment = fragmentManager.findFragmentByTag(String.valueOf(containerViewId)); if (fragment instanceof TextSearchFragment) { @@ -240,7 +240,11 @@ public void onClick(View view) { }); fragmentView.findViewById(R.id.closeImageButton).setOnClickListener(view -> hide()); - fragmentView.findViewById(R.id.filterImageButton).setOnClickListener(view -> MarkorDialogFactory.showSearchDialog(activity, editText, searchEditText.getText().toString())); + fragmentView.findViewById(R.id.filterImageButton).setOnClickListener(view -> { + if (editText.getView() instanceof EditText) { + MarkorDialogFactory.showSearchDialog(activity, (EditText) editText.getView(), searchEditText.getText().toString()); + } + }); fragmentView.findViewById(R.id.toggleImageButton).setOnClickListener(view -> toggleFindReplaceLayout(fragmentView)); fragmentView.findViewById(R.id.previousImageButton).setOnClickListener(view -> textSearchHandler.previous(editText)); fragmentView.findViewById(R.id.nextImageButton).setOnClickListener(view -> textSearchHandler.next(editText)); diff --git a/app/src/main/java/net/gsantner/markor/frontend/textsearch/TextSearchHandler.java b/app/src/main/java/net/gsantner/markor/frontend/textsearch/TextSearchHandler.java index 76ab1b0c7e..a95ad359a6 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textsearch/TextSearchHandler.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textsearch/TextSearchHandler.java @@ -3,7 +3,7 @@ import android.text.Editable; import android.widget.EditText; -import net.gsantner.markor.frontend.textview.HighlightingEditor; +import net.gsantner.markor.frontend.textview.MarkorEditor; import net.gsantner.markor.frontend.textview.SyntaxHighlighterBase; import net.gsantner.markor.frontend.textview.TextViewUtils; @@ -38,7 +38,7 @@ default void onResultChanged(int active, int count) { void onResultChanged(int active, int count, String msg); } - public void find(HighlightingEditor editText, String target, int activeIndex) { + public void find(MarkorEditor editText, String target, int activeIndex) { matches.clear(); if (editText == null) { resultChangedListener.onResultChanged(0, 0); @@ -110,7 +110,7 @@ private void loadMatches(Matcher matcher, int selectionStart) { } } - private void highlightMatches(HighlightingEditor editText) { + private void highlightMatches(MarkorEditor editText) { if (editText == null) { return; } @@ -171,7 +171,7 @@ private int findNearbyIndex(Selection selection, ArrayList matches) { return Math.max(i - 1, 0); } - public void jump(HighlightingEditor editText, int index, boolean setSelection) { + public void jump(MarkorEditor editText, int index, boolean setSelection) { if (editText == null) { resultChangedListener.onResultChanged(0, 0); return; @@ -196,12 +196,14 @@ public void jump(HighlightingEditor editText, int index, boolean setSelection) { resultChangedListener.onResultChanged(currentIndex, size); int start = match.getStart(); - TextViewUtils.showSelection(editText, start); + if (editText.getView() instanceof EditText) { + TextViewUtils.showSelection((EditText) editText.getView(), start); + } markSelection(editText, start, match.getEnd(), setSelection); editText.applyDynamicHighlight(); } - public void previous(HighlightingEditor editText) { + public void previous(MarkorEditor editText) { if (editText == null) { resultChangedListener.onResultChanged(0, 0); return; @@ -217,7 +219,9 @@ public void previous(HighlightingEditor editText) { Match currentMatch = matches.get(currentIndex); if (editText.hasFocus() && editText.getSelectionStart() > currentMatch.getEnd()) { markSelection(editText, currentMatch.getStart(), currentMatch.getEnd(), true); - TextViewUtils.showSelection(editText, currentMatch.getStart()); + if (editText.getView() instanceof EditText) { + TextViewUtils.showSelection((EditText) editText.getView(), currentMatch.getStart()); + } return; } @@ -231,7 +235,9 @@ public void previous(HighlightingEditor editText) { resultChangedListener.onResultChanged(currentIndex, size); int start = match.getStart(); - TextViewUtils.showSelection(editText, start); + if (editText.getView() instanceof EditText) { + TextViewUtils.showSelection((EditText) editText.getView(), start); + } markSelection(editText, start, match.getEnd(), true); editText.applyDynamicHighlight(); } else { @@ -240,7 +246,7 @@ public void previous(HighlightingEditor editText) { } } - public void next(HighlightingEditor editText) { + public void next(MarkorEditor editText) { if (editText == null) { resultChangedListener.onResultChanged(0, 0); return; @@ -256,7 +262,9 @@ public void next(HighlightingEditor editText) { Match currentMatch = matches.get(currentIndex); if (editText.hasFocus() && editText.getSelectionStart() <= currentMatch.getStart()) { markSelection(editText, currentMatch.getStart(), currentMatch.getEnd(), true); - TextViewUtils.showSelection(editText, currentMatch.getStart()); + if (editText.getView() instanceof EditText) { + TextViewUtils.showSelection((EditText) editText.getView(), currentMatch.getStart()); + } return; } @@ -270,7 +278,9 @@ public void next(HighlightingEditor editText) { resultChangedListener.onResultChanged(currentIndex, size); int start = match.getStart(); - TextViewUtils.showSelection(editText, start); + if (editText.getView() instanceof EditText) { + TextViewUtils.showSelection((EditText) editText.getView(), start); + } markSelection(editText, start, match.getEnd(), true); editText.applyDynamicHighlight(); } else { @@ -311,7 +321,7 @@ private String applyPreserveCase(String originalText, String replacement) { return replacement; } - public int replace(HighlightingEditor editText, String replacement) { + public int replace(MarkorEditor editText, String replacement) { if (editText == null || matches.isEmpty()) { resultChangedListener.onResultChanged(0, 0); return 0; @@ -358,7 +368,7 @@ public int replace(HighlightingEditor editText, String replacement) { return matches.size(); } - public void replaceAll(HighlightingEditor editText, String replacement) { + public void replaceAll(MarkorEditor editText, String replacement) { if (editText == null || matches.isEmpty()) { resultChangedListener.onResultChanged(0, 0); return; @@ -388,7 +398,7 @@ public void replaceAll(HighlightingEditor editText, String replacement) { private final Selection selection = new Selection(); // Search selection - private void markSelection(EditText editText, int start, int end, boolean setSelection) { + private void markSelection(MarkorEditor editText, int start, int end, boolean setSelection) { selection.setStart(start); selection.setEnd(end); @@ -397,14 +407,14 @@ private void markSelection(EditText editText, int start, int end, boolean setSel } } - public void clearSearchSelection(HighlightingEditor editText, boolean force) { + public void clearSearchSelection(MarkorEditor editText, boolean force) { if (force || selection.isSelected()) { editText.clearSearchSelection(); selection.reset(); } } - public void handleSearchSelection(HighlightingEditor editText, EditText searchEditText) { + public void handleSearchSelection(MarkorEditor editText, EditText searchEditText) { clearSearchSelection(editText, false); selection.setStart(editText.getSelectionStart()); 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 1a7333f183..86c80a97f6 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 @@ -30,6 +30,7 @@ import androidx.annotation.ColorInt; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.appcompat.widget.AppCompatEditText; @@ -50,13 +51,14 @@ import java.util.concurrent.atomic.AtomicBoolean; @SuppressWarnings("UnusedReturnValue") -public class HighlightingEditor extends AppCompatEditText { +public class HighlightingEditor extends AppCompatEditText implements MarkorEditor { final static int HIGHLIGHT_SHIFT_LINES = 8; // Lines to scroll before hl updated 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%%"; + // Tokens are now defined in MarkorEditor interface — kept here for backward compat + public final static String PLACE_CURSOR_HERE_TOKEN = MarkorEditor.PLACE_CURSOR_HERE_TOKEN; + public final static String INSERT_SELECTION_HERE_TOKEN = MarkorEditor.INSERT_SELECTION_HERE_TOKEN; private boolean _accessibilityEnabled = true; private final boolean _isSpellingRedUnderline; @@ -608,4 +610,14 @@ public void onDestroyActionMode(ActionMode mode) { public int getTextChangedNumber() { return _textChangedNumber; } + + // MarkorEditor interface + // --------------------------------------------------------------------------------------------- + + @NonNull + @Override + public View getView() { + return this; + } + } diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/MarkorEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/MarkorEditor.java new file mode 100644 index 0000000000..f5d1961d09 --- /dev/null +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/MarkorEditor.java @@ -0,0 +1,136 @@ +/*####################################################### + * + * Maintained 2017-2026 by Gregor Santner + * License of this file: Apache 2.0 + * https://www.apache.org/licenses/LICENSE-2.0 + * + #########################################################*/ +package net.gsantner.markor.frontend.textview; + +import android.text.Editable; +import android.text.InputFilter; +import android.text.TextWatcher; +import android.view.View; + +import androidx.annotation.ColorInt; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import net.gsantner.opoc.wrapper.GsCallback; + +import java.util.List; + +/** + * Common interface for all Markor text editors (HighlightingEditor, RecyclerTextEditor, etc.). + * This allows format-specific features (action buttons, search, highlighting) to work with + * any editor implementation interchangeably. + */ +public interface MarkorEditor { + + String PLACE_CURSOR_HERE_TOKEN = "%%PLACE_CURSOR_HERE%%"; + String INSERT_SELECTION_HERE_TOKEN = "%%INSERT_SELECTION_HERE%%"; + + // ---- Text content ---- + + @Nullable + Editable getText(); + + void setText(@Nullable CharSequence text); + + int length(); + + // ---- Selection / cursor ---- + + void setSelection(int index); + + void setSelection(int start, int stop); + + int getSelectionStart(); + + int getSelectionEnd(); + + boolean hasSelection(); + + void selectLines(); + + // ---- Text manipulation ---- + + void insertOrReplaceTextOnCursor(String newText); + + void simulateKeyPress(int keyEvent_KEYCODE_SOMETHING); + + // ---- Auto-format ---- + + void setAutoFormatters(@Nullable InputFilter inputFilter, @Nullable TextWatcher modifier); + + boolean getAutoFormatEnabled(); + + void setAutoFormatEnabled(boolean enable); + + void withAutoFormatDisabled(@NonNull GsCallback.a0 callback); + + // ---- Syntax highlighting ---- + + void setHighlighter(@Nullable SyntaxHighlighterBase highlighter); + + @Nullable + SyntaxHighlighterBase getHighlighter(); + + boolean setHighlightingEnabled(boolean enable); + + boolean getHighlightingEnabled(); + + void recomputeHighlighting(); + + void initHighlighter(); + + // ---- Search highlight ---- + + void setSearchMatches(@Nullable List spanGroups); + + void removeSearchMatch(@Nullable SyntaxHighlighterBase.SpanGroup spanGroup); + + void clearSearchMatches(); + + void applyDynamicHighlight(); + + void addSearchSelection(int start, int end, @ColorInt int color); + + void clearSearchSelection(); + + // ---- Focus & view ---- + + boolean requestFocus(); + + boolean hasFocus(); + + /** + * Returns the underlying Android View for this editor. + * For HighlightingEditor this is the editor itself (it extends EditText). + * For RecyclerTextEditor-based implementations, this is the RecyclerView. + */ + @NonNull + View getView(); + + void setOnFocusChangeListener(@Nullable View.OnFocusChangeListener listener); + + // ---- TextWatcher support (for search fragment) ---- + + void addTextChangedListener(@NonNull TextWatcher watcher); + + void removeTextChangedListener(@NonNull TextWatcher watcher); + + // ---- Miscellaneous ---- + + int getTextChangedNumber(); + + void setSaveInstanceState(boolean save); + + boolean indexesValid(int... indexes); + + // ---- Cursor movement ---- + + int moveCursorToEndOfLine(int offset); + + int moveCursorToBeginOfLine(int offset); +} diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java index 71a03071bb..e31239fe09 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -10,24 +10,36 @@ import android.content.Context; import android.graphics.Typeface; import android.text.Editable; +import android.text.InputFilter; +import android.text.Selection; +import android.text.SpannableStringBuilder; import android.text.TextWatcher; import android.util.AttributeSet; import android.util.TypedValue; import android.view.Gravity; +import android.view.KeyEvent; +import android.view.View; import android.view.ViewGroup; +import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatEditText; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; +import net.gsantner.opoc.format.GsTextUtils; +import net.gsantner.opoc.wrapper.GsCallback; + import java.util.ArrayList; +import java.util.List; -public class RecyclerTextEditor extends RecyclerView { +public class RecyclerTextEditor extends RecyclerView implements MarkorEditor { private final ArrayList _lines = new ArrayList<>(); private final LinesAdapter _adapter = new LinesAdapter(); private final ArrayList _textChangedListeners = new ArrayList<>(); + private final ArrayList _textWatcherListeners = new ArrayList<>(); + private final ArrayList _matches = new ArrayList<>(); private boolean _trailingNewline; private float _textSizeSp = 16f; @@ -36,6 +48,17 @@ public class RecyclerTextEditor extends RecyclerView { private int _backgroundColor; private Typeface _typeface; private boolean _wrapEnabled = true; + private int _selectionStart; + private int _selectionEnd; + private InputFilter _autoFormatFilter; + private TextWatcher _autoFormatModifier; + private boolean _autoFormatEnabled; + private SyntaxHighlighterBase _highlighter; + private boolean _hlEnabled; + private SyntaxHighlighterBase.SpanGroup _searchSelection; + private int _textChangedNumber; + private final Runnable _textChangedRecorder = TextViewUtils.makeDebounced(getHandler(), 1000, () -> _textChangedNumber++); + private boolean _saveInstanceState = true; public RecyclerTextEditor(@NonNull Context context) { this(context, null); @@ -47,8 +70,11 @@ public RecyclerTextEditor(@NonNull Context context, @Nullable AttributeSet attrs setAdapter(_adapter); setItemAnimator(null); _lines.add(""); + _selectionStart = 0; + _selectionEnd = 0; } + @Override public void setText(@Nullable CharSequence text) { _lines.clear(); _trailingNewline = false; @@ -70,11 +96,15 @@ public void setText(@Nullable CharSequence text) { if (_lines.isEmpty()) { _lines.add(""); } + _selectionStart = clampToLength(_selectionStart); + _selectionEnd = clampToLength(_selectionEnd); _adapter.notifyDataSetChanged(); + applySelectionToVisibleLineEditor(); } @NonNull - public CharSequence getText() { + @Override + public Editable getText() { final StringBuilder sb = new StringBuilder(); for (int i = 0; i < _lines.size(); i++) { if (i > 0) { @@ -85,9 +115,16 @@ public CharSequence getText() { if (_trailingNewline && !_lines.isEmpty()) { sb.append('\n'); } - return sb; + final SpannableStringBuilder fullText = new SpannableStringBuilder(sb); + final int selStart = clampToLength(_selectionStart); + final int selEnd = clampToLength(_selectionEnd); + if (GsTextUtils.inRange(0, fullText.length(), selStart, selEnd)) { + Selection.setSelection(fullText, selStart, selEnd); + } + return fullText; } + @Override public int length() { int total = _trailingNewline && !_lines.isEmpty() ? 1 : 0; for (int i = 0; i < _lines.size(); i++) { @@ -149,11 +186,373 @@ private void notifyTextChanged() { } } + @Override + public void addTextChangedListener(@NonNull TextWatcher watcher) { + if (!_textWatcherListeners.contains(watcher)) { + _textWatcherListeners.add(watcher); + } + } + + @Override + public void removeTextChangedListener(@NonNull TextWatcher watcher) { + _textWatcherListeners.remove(watcher); + } + + @Override + public void setSelection(int index) { + if (indexesValid(index)) { + _selectionStart = index; + _selectionEnd = index; + applySelectionToVisibleLineEditor(); + } + } + + @Override + public void setSelection(int start, int stop) { + if (indexesValid(start, stop)) { + _selectionStart = start; + _selectionEnd = stop; + applySelectionToVisibleLineEditor(); + } else if (indexesValid(start, stop - 1)) { + _selectionStart = start; + _selectionEnd = stop - 1; + applySelectionToVisibleLineEditor(); + } else if (indexesValid(start + 1, stop)) { + _selectionStart = start + 1; + _selectionEnd = stop; + applySelectionToVisibleLineEditor(); + } + } + + @Override + public int getSelectionStart() { + return _selectionStart; + } + + @Override + public int getSelectionEnd() { + return _selectionEnd; + } + + @Override + public boolean hasSelection() { + return _selectionStart != _selectionEnd; + } + + @Override + public void selectLines() { + final int[] lineSelection = TextViewUtils.getLineSelection(getText()); + setSelection(lineSelection[0], lineSelection[1]); + } + + @Override + public void insertOrReplaceTextOnCursor(String newText) { + final Editable editable = getText(); + if (editable == null || newText == null) { + return; + } + + final int selStart = Math.min(_selectionStart, _selectionEnd); + final int selEnd = Math.max(_selectionStart, _selectionEnd); + final CharSequence selected = editable.subSequence(selStart, selEnd); + String expanded = newText.replace(INSERT_SELECTION_HERE_TOKEN, selected); + + final int newCursorPos = expanded.indexOf(PLACE_CURSOR_HERE_TOKEN); + final String finalText = expanded.replace(PLACE_CURSOR_HERE_TOKEN, ""); + + editable.replace(selStart, selEnd, finalText); + setText(editable); + + if (newCursorPos >= 0) { + setSelection(selStart + newCursorPos); + } else { + setSelection(selStart + finalText.length()); + } + } + + @Override + public void simulateKeyPress(int keyEvent_KEYCODE_SOMETHING) { + final AppCompatEditText focused = findFocusedLineEditor(); + if (focused == null) { + return; + } + focused.dispatchKeyEvent(new KeyEvent(0, 0, KeyEvent.ACTION_DOWN, keyEvent_KEYCODE_SOMETHING, 0)); + focused.dispatchKeyEvent(new KeyEvent(0, 0, KeyEvent.ACTION_UP, keyEvent_KEYCODE_SOMETHING, 0)); + } + + @Override + public void setAutoFormatters(@Nullable InputFilter inputFilter, @Nullable TextWatcher modifier) { + _autoFormatFilter = inputFilter; + _autoFormatModifier = modifier; + } + + @Override + public boolean getAutoFormatEnabled() { + return _autoFormatEnabled; + } + + @Override + public void setAutoFormatEnabled(boolean enable) { + _autoFormatEnabled = enable; + } + + @Override + public void withAutoFormatDisabled(@NonNull GsCallback.a0 callback) { + final boolean enabled = _autoFormatEnabled; + try { + _autoFormatEnabled = false; + callback.callback(); + } finally { + _autoFormatEnabled = enabled; + } + } + + @Override + public void setHighlighter(@Nullable SyntaxHighlighterBase highlighter) { + _highlighter = highlighter; + if (_hlEnabled) { + recomputeHighlighting(); + } + } + + @Nullable + @Override + public SyntaxHighlighterBase getHighlighter() { + return _highlighter; + } + + @Override + public boolean setHighlightingEnabled(boolean enable) { + final boolean previous = _hlEnabled; + _hlEnabled = enable; + if (_hlEnabled) { + initHighlighter(); + } + return previous; + } + + @Override + public boolean getHighlightingEnabled() { + return _hlEnabled; + } + + @Override + public void recomputeHighlighting() { + if (_hlEnabled && _highlighter != null) { + _highlighter.setSpannable(getText()).configure(); + } + } + + @Override + public void initHighlighter() { + if (_highlighter != null) { + _highlighter.setSpannable(getText()).configure(); + } + } + + @Override + public void setSearchMatches(@Nullable List spanGroups) { + _matches.clear(); + if (spanGroups != null) { + _matches.addAll(spanGroups); + } + } + + @Override + public void removeSearchMatch(@Nullable SyntaxHighlighterBase.SpanGroup spanGroup) { + _matches.remove(spanGroup); + } + + @Override + public void clearSearchMatches() { + _matches.clear(); + } + + @Override + public void applyDynamicHighlight() { + } + + @Override + public void addSearchSelection(int start, int end, @ColorInt int color) { + _searchSelection = SyntaxHighlighterBase.createBackgroundHighlight(start, end, color); + } + + @Override + public void clearSearchSelection() { + _searchSelection = null; + } + + @NonNull + @Override + public View getView() { + return this; + } + + @Override + public void setOnFocusChangeListener(@Nullable OnFocusChangeListener listener) { + super.setOnFocusChangeListener(listener); + } + + @Override + public int getTextChangedNumber() { + return _textChangedNumber; + } + + @Override + public void setSaveInstanceState(boolean save) { + _saveInstanceState = save; + } + + @Override + public boolean indexesValid(int... indexes) { + return GsTextUtils.inRange(0, length(), indexes); + } + + @Override + public int moveCursorToEndOfLine(int offset) { + final int[] lineCol = globalOffsetToLineCol(_selectionEnd); + final int lineEnd = lineColToGlobalOffset(lineCol[0], _lines.get(lineCol[0]).length()); + final int newPos = clampToLength(lineEnd + offset); + setSelection(newPos); + return getSelectionStart(); + } + + @Override + public int moveCursorToBeginOfLine(int offset) { + final int[] lineCol = globalOffsetToLineCol(_selectionEnd); + final int lineStart = lineColToGlobalOffset(lineCol[0], 0); + final int newPos = clampToLength(lineStart + offset); + setSelection(newPos); + return getSelectionStart(); + } + + /** + * Convert global character offset to (lineIndex, columnInLine). + * Returns int[]{lineIndex, column}. If offset is past end, clamps to end. + */ + private int[] globalOffsetToLineCol(int offset) { + if (_lines.isEmpty()) { + return new int[]{0, 0}; + } + + int remaining = clampToLength(offset); + final int lastIndex = _lines.size() - 1; + for (int i = 0; i < _lines.size(); i++) { + final int lineLen = _lines.get(i).length(); + if (remaining <= lineLen) { + return new int[]{i, remaining}; + } + remaining -= lineLen; + + final boolean hasLineBreakAfter = i < lastIndex || _trailingNewline; + if (hasLineBreakAfter) { + if (remaining == 0) { + if (i < lastIndex) { + return new int[]{i + 1, 0}; + } + return new int[]{i, lineLen}; + } + remaining -= 1; + } + } + return new int[]{lastIndex, _lines.get(lastIndex).length()}; + } + + /** + * Convert (lineIndex, column) to global character offset. + */ + private int lineColToGlobalOffset(int lineIndex, int column) { + if (_lines.isEmpty()) { + return 0; + } + + final int safeLine = Math.max(0, Math.min(lineIndex, _lines.size() - 1)); + int offset = 0; + for (int i = 0; i < safeLine; i++) { + offset += _lines.get(i).length() + 1; + } + final int safeCol = Math.max(0, Math.min(column, _lines.get(safeLine).length())); + return Math.max(0, Math.min(offset + safeCol, length())); + } + + private int clampToLength(int value) { + return Math.max(0, Math.min(value, length())); + } + + private void updateSelectionFromLineEditor(int lineIndex, int localStart, int localEnd) { + _selectionStart = lineColToGlobalOffset(lineIndex, localStart); + _selectionEnd = lineColToGlobalOffset(lineIndex, localEnd); + } + + private void applySelectionToVisibleLineEditor() { + final int selStart = clampToLength(_selectionStart); + final int selEnd = clampToLength(_selectionEnd); + final int[] startLineCol = globalOffsetToLineCol(selStart); + final int[] endLineCol = globalOffsetToLineCol(selEnd); + final int targetLine = endLineCol[0]; + + final LineViewHolder holder = (LineViewHolder) findViewHolderForAdapterPosition(targetLine); + if (holder == null) { + scrollToPosition(targetLine); + post(this::applySelectionToVisibleLineEditor); + return; + } + + final AppCompatEditText edit = holder._edit; + if (!edit.hasFocus()) { + edit.requestFocus(); + } + + if (startLineCol[0] == endLineCol[0]) { + final int localStart = Math.min(startLineCol[1], endLineCol[1]); + final int localEnd = Math.max(startLineCol[1], endLineCol[1]); + edit.setSelection(localStart, localEnd); + } else { + edit.setSelection(endLineCol[1]); + } + } + + @Nullable + private AppCompatEditText findFocusedLineEditor() { + final View focused = findFocus(); + if (focused instanceof AppCompatEditText) { + return (AppCompatEditText) focused; + } + return null; + } + + private void dispatchBeforeTextChanged(CharSequence s, int start, int count, int after) { + for (TextWatcher watcher : new ArrayList<>(_textWatcherListeners)) { + watcher.beforeTextChanged(s, start, count, after); + } + } + + private void dispatchOnTextChanged(CharSequence s, int start, int before, int count) { + for (TextWatcher watcher : new ArrayList<>(_textWatcherListeners)) { + watcher.onTextChanged(s, start, before, count); + } + } + + private void dispatchAfterTextChanged(Editable s) { + for (TextWatcher watcher : new ArrayList<>(_textWatcherListeners)) { + watcher.afterTextChanged(s); + } + } + private final class LinesAdapter extends RecyclerView.Adapter { @NonNull @Override public LineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - final AppCompatEditText edit = new AppCompatEditText(parent.getContext()); + final AppCompatEditText edit = new AppCompatEditText(parent.getContext()) { + @Override + protected void onSelectionChanged(int selStart, int selEnd) { + super.onSelectionChanged(selStart, selEnd); + final Object tag = getTag(); + if (tag instanceof Integer) { + updateSelectionFromLineEditor((Integer) tag, selStart, selEnd); + } + } + }; final RecyclerView.LayoutParams lp = new RecyclerView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT @@ -191,6 +590,7 @@ private void bind(int position) { _edit.removeTextChangedListener(_watcher); } + _edit.setTag(position); _edit.setText(_lines.get(position)); _edit.setTextSize(TypedValue.COMPLEX_UNIT_SP, _textSizeSp); _edit.setTypeface(_typeface); @@ -198,14 +598,33 @@ private void bind(int position) { _edit.setBackgroundColor(_backgroundColor); _edit.setLineSpacing(0f, _lineSpacingMultiplier); _edit.setHorizontallyScrolling(!_wrapEnabled); + _edit.setOnFocusChangeListener((v, hasFocus) -> { + if (hasFocus) { + updateSelectionFromLineEditor(position, _edit.getSelectionStart(), _edit.getSelectionEnd()); + } + }); _watcher = new TextWatcher() { + int _globalStart; + @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { + final int pos = getBindingAdapterPosition(); + if (pos == NO_POSITION) { + return; + } + _globalStart = lineColToGlobalOffset(pos, start); + dispatchBeforeTextChanged(getText(), _globalStart, count, after); } @Override public void onTextChanged(CharSequence s, int start, int before, int count) { + final int pos = getBindingAdapterPosition(); + if (pos == NO_POSITION) { + return; + } + _lines.set(pos, s != null ? s.toString() : ""); + dispatchOnTextChanged(getText(), _globalStart, before, count); } @Override @@ -213,11 +632,19 @@ public void afterTextChanged(Editable s) { final int pos = getBindingAdapterPosition(); if (pos != NO_POSITION) { _lines.set(pos, s != null ? s.toString() : ""); + updateSelectionFromLineEditor(pos, _edit.getSelectionStart(), _edit.getSelectionEnd()); + dispatchAfterTextChanged(getText()); + _textChangedRecorder.run(); notifyTextChanged(); } } }; _edit.addTextChangedListener(_watcher); + + final int[] selectionLineCol = globalOffsetToLineCol(_selectionEnd); + if (selectionLineCol[0] == position && !_edit.hasFocus()) { + _edit.setSelection(selectionLineCol[1]); + } } } } 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 f6992d1e03..9036dc8159 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 @@ -435,7 +435,7 @@ public static String interpolateSnippet(String text, final CharSequence title, f .replace("{{title}}", title) .replace("{{weekday}}", weekday) .replace("{{sel}}", selectedText) - .replace("{{cursor}}", HighlightingEditor.PLACE_CURSOR_HERE_TOKEN); + .replace("{{cursor}}", MarkorEditor.PLACE_CURSOR_HERE_TOKEN); while (text.contains("{{uuid}}")) { text = text.replaceFirst("\\{\\{uuid\\}\\}", UUID.randomUUID().toString()); From f71936faf3763dce39ca9494800f37b0656c2a75 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Sat, 14 Mar 2026 18:42:32 +0100 Subject: [PATCH 06/18] docs: update lrq3000 contribution's description in CONTRIBUTORS.md Signed-off-by: Stephen L. --- CONTRIBUTORS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 792f52f64e..2e76f69a08 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -55,4 +55,4 @@ Where: * **[Matthew White](https://github.com/mehw)**
~° Zim-Wiki link/attachment conformance * **[Markus Paintner](https://github.com/goli4thus)**
~° Added duplicate lines action * **[Janez Pavel Žebovec](https://janezpavelzebovec.net/)**
~° Slovenian translation -* **[Stephen Karl Larroque](https://github.com/lrq3000/)**
~° Bugfixes +* **[Stephen Karl Larroque](https://github.com/lrq3000/)**
~° Bugfixes and large files speed performance From 249cd139c6f7cb7477dd9d97e2a48184f5546034 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Tue, 17 Mar 2026 02:41:03 +0100 Subject: [PATCH 07/18] feat: lower threshold from 1 MiB down to 512 KiB to enable large files editor using RecyclerView Signed-off-by: Stephen L. --- .../gsantner/markor/activity/DocumentEditAndViewFragment.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f9ed5988ed..32af35b156 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java @@ -81,7 +81,7 @@ public class DocumentEditAndViewFragment extends MarkorBaseFragment implements F public static final String FRAGMENT_TAG = "DocumentEditAndViewFragment"; public static final String SAVESTATE_DOCUMENT = "DOCUMENT"; public static final String START_PREVIEW = "START_PREVIEW"; - private static final long RECYCLER_EDITOR_FILE_BYTES_THRESHOLD = 1024L * 1024L; + private static final long RECYCLER_EDITOR_FILE_BYTES_THRESHOLD = 500L * 1024L; public static float VIEW_FONT_SCALE = 100f / 15.7f; From 2b0868188f5ed8f48a018d1a36766d3162b9f1e8 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Tue, 17 Mar 2026 03:22:53 +0100 Subject: [PATCH 08/18] fix: restore format actions and TOC navigation functionalities in RecyclerTextEditor large files text editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The root causes were a mix of hard feature gates in the fragment (search/title actions/action-bar recreation disabled in recycler mode), headline dialogs still requiring `EditText`, and a non-live recycler `Editable` path that made many action-button edits not persist. I fixed those and also wired recycler highlighting startup so syntax highlighting now actually turns on for large files. **What is now fixed** - Recycler mode now supports top-bar title click and long-click actions (TOC + jump top/bottom) through the same format action pipeline as classic editor via `app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java`. - Search action is enabled in recycler mode (removed large-file block) in `app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java`. - Bottom formatting bar now recreates correctly on edit/preview toggles in recycler mode in `app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java`. - Headline dialog was generalized to `MarkorEditor` (no unsafe recycler cast), and markdown/wikitext now call that path: - `app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java` - `app/src/main/java/net/gsantner/markor/format/markdown/MarkdownActionButtons.java` - `app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java` - `RecyclerTextEditor` now uses a live editor-backed `Editable` and syncs mutations back into line rows; action-button text mutations persist and highlighting spans are rendered per visible row: - `app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java` - Highlighting is now enabled on startup for whichever editor is active (classic or recycler) in `app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java`. Signed-off-by: Stephen L. using OpenCode and Oh My OpenCode with ChatGPT-Codex-5.3 and ultrawork mode --- .../activity/DocumentEditAndViewFragment.java | 18 +- .../markdown/MarkdownActionButtons.java | 5 +- .../wikitext/WikitextActionButtons.java | 5 +- .../markor/frontend/MarkorDialogFactory.java | 30 +- .../frontend/textview/RecyclerTextEditor.java | 314 ++++++++++++------ 5 files changed, 251 insertions(+), 121 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 32af35b156..2a27f764a9 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java @@ -201,8 +201,10 @@ public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { _hlEditor.setBackgroundColor(_appSettings.getEditorBackgroundColor()); _hlEditor.setTextColor(_appSettings.getEditorForegroundColor()); _hlEditor.setGravity(_appSettings.isEditorStartEditingInCenter() ? Gravity.CENTER : Gravity.NO_GRAVITY); - _hlEditor.setHighlightingEnabled(_appSettings.getDocumentHighlightState(_document.path, _hlEditor.getText())); - _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); + if (_activeEditor != null) { + _activeEditor.setHighlightingEnabled(_appSettings.getDocumentHighlightState(_document.path, _activeEditor.getText())); + _activeEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); + } _hlEditor.setSaveInstanceState(false); // We will reload from disk _hlEditor.setOverScrollMode(View.OVER_SCROLL_ALWAYS); @@ -313,7 +315,7 @@ public void onPause() { } _appSettings.addRecentFile(_document.file); _appSettings.setDocumentPreviewState(_document.path, _isPreviewVisible); - _appSettings.setLastEditPosition(_document.path, _isRecyclerEditorEnabled ? 0 : TextViewUtils.getSelection(_hlEditor)[0]); + _appSettings.setLastEditPosition(_document.path, _activeEditor != null ? _activeEditor.getSelectionStart() : 0); _appSettings.setLastEditScrollY(_document.path, _isRecyclerEditorEnabled ? _recyclerEditor.computeVerticalScrollOffset() : _verticalScrollView.getScrollY()); if (_document.path.equals(_appSettings.getTodoFile().getAbsolutePath())) { TodoWidgetProvider.updateTodoWidgets(); @@ -586,10 +588,6 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { return true; } case R.id.action_search: { - if (_isRecyclerEditorEnabled) { - Toast.makeText(activity, R.string.large_file_action_not_supported, Toast.LENGTH_SHORT).show(); - return true; - } setViewModeVisibility(false); _format.getActions().onSearch(); return true; @@ -1133,7 +1131,7 @@ public void setViewModeVisibility(boolean show, final boolean animate) { } show |= _document.isBinaryFileNoTextLoading(); - if (!_isRecyclerEditorEnabled) { + if (_format != null) { _format.getActions().recreateActionButtons(_textActionsBar, show ? ActionButtonBase.ActionItem.DisplayMode.VIEW : ActionButtonBase.ActionItem.DisplayMode.EDIT); } showHideActionBar(); @@ -1172,14 +1170,14 @@ public void webViewJavascriptCallback(final String[] jsArgs) { @Override protected void onToolbarClicked(View v) { - if (!_isRecyclerEditorEnabled && _format != null) { + if (_format != null) { _format.getActions().runTitleClick(); } } @Override protected boolean onToolbarLongClicked(View v) { - if (!_isRecyclerEditorEnabled && isVisible() && isResumed()) { + if (isVisible() && isResumed() && _format != null) { _format.getActions().runJumpBottomTopAction(_isPreviewVisible ? ActionButtonBase.ActionItem.DisplayMode.VIEW : ActionButtonBase.ActionItem.DisplayMode.EDIT); return true; } diff --git a/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownActionButtons.java b/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownActionButtons.java index cb938de38f..cf0fcfff40 100644 --- a/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownActionButtons.java +++ b/app/src/main/java/net/gsantner/markor/format/markdown/MarkdownActionButtons.java @@ -220,6 +220,9 @@ public boolean onActionLongClick(final @StringRes int action) { pos -> { if (_hlEditor.getView() instanceof EditText) { TextViewUtils.setSelectionAndShow((EditText) _hlEditor.getView(), pos); + } else { + _hlEditor.requestFocus(); + _hlEditor.setSelection(pos); } }); return true; @@ -326,7 +329,7 @@ private void insertTableRow(int cols, boolean isHeaderEnabled) { @Override public boolean runTitleClick() { final Matcher m = MarkdownReplacePatternGenerator.PREFIX_ATX_HEADING.matcher(""); - MarkorDialogFactory.showHeadlineDialog(getActivity(), (EditText) _hlEditor.getView(), _webView, _headlineDialogState, (text, start, end) -> { + MarkorDialogFactory.showHeadlineDialog(getActivity(), _hlEditor, _webView, _headlineDialogState, (text, start, end) -> { if (m.reset(text.subSequence(start, end)).find()) { return m.end(2) - m.start(2) - 1; } diff --git a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java index ecb77dc445..44330794f3 100644 --- a/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java +++ b/app/src/main/java/net/gsantner/markor/format/wikitext/WikitextActionButtons.java @@ -10,7 +10,6 @@ import android.content.Context; import android.os.Build; import android.view.KeyEvent; -import android.widget.EditText; import androidx.annotation.NonNull; import androidx.annotation.StringRes; @@ -259,7 +258,7 @@ public static String createWikitextHeaderAndTitleContents(String fileNameWithout @Override public boolean runTitleClick() { final Matcher m = WikitextSyntaxHighlighter.HEADING.matcher(""); - MarkorDialogFactory.showHeadlineDialog(getActivity(), (EditText) _hlEditor.getView(), _webView, _headlineDialogState, (text, start, end) -> { + MarkorDialogFactory.showHeadlineDialog(getActivity(), _hlEditor, _webView, _headlineDialogState, (text, start, end) -> { if (m.reset(text.subSequence(start, end)).find()) { return 7 - (m.end(2) - m.start(2)); } @@ -287,4 +286,4 @@ public boolean onReceiveKeyPress(final int keyCode, final KeyEvent event) { return super.onReceiveKeyPress(keyCode, event); } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java b/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java index ce8336ec5a..119c6b9af4 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java +++ b/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java @@ -53,7 +53,7 @@ import net.gsantner.markor.frontend.filesearch.FileSearchDialog; import net.gsantner.markor.frontend.filesearch.FileSearchEngine; import net.gsantner.markor.frontend.filesearch.FileSearchResultSelectorDialog; -import net.gsantner.markor.frontend.textview.HighlightingEditor; +import net.gsantner.markor.frontend.textview.MarkorEditor; import net.gsantner.markor.frontend.textview.SyntaxHighlighterBase; import net.gsantner.markor.frontend.textview.TextViewUtils; import net.gsantner.markor.model.AppSettings; @@ -795,16 +795,29 @@ public static void showHeadlineDialog( final WebView webView, final ActionButtonBase.HeadlineState state, final GsCallback.r3 levelCallback + ) { + showHeadlineDialog(activity, (MarkorEditor) edit, webView, state, levelCallback); + } + + public static void showHeadlineDialog( + final Activity activity, + final MarkorEditor editor, + final WebView webView, + final ActionButtonBase.HeadlineState state, + final GsCallback.r3 levelCallback ) { int textChangedNumber = 0; - if (edit instanceof HighlightingEditor) { - textChangedNumber = ((HighlightingEditor) edit).getTextChangedNumber(); + if (editor != null) { + textChangedNumber = editor.getTextChangedNumber(); } if (textChangedNumber != state.lastTextChangedNumber) { state.lastTextChangedNumber = textChangedNumber; // Get all headings and their levels - final CharSequence text = edit.getText(); + final CharSequence text = editor != null ? editor.getText() : null; + if (text == null) { + return; + } state.headings.clear(); GsTextUtils.forEachline(text, (line, start, end) -> { final int level = levelCallback.callback(text, start, end); @@ -850,8 +863,13 @@ public void callback(Spannable spannable) { dopt.positionCallback = result -> { final int index = filtered.get(result.get(0)); final int line = state.headings.get(index).line; - - TextViewUtils.selectLines(edit, line); + final CharSequence text = editor != null ? editor.getText() : null; + if (editor != null && text != null) { + final int[] sel = TextViewUtils.getLineSelection(text, TextViewUtils.getIndexFromLineOffset(text, line, 0)); + if (sel[0] >= 0 && sel[1] >= 0) { + editor.setSelection(sel[0], sel[1]); + } + } final String jumpJs = "document.querySelector('[line=\"" + line + "\"]').scrollIntoView();"; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && webView != null) { diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java index e31239fe09..9da3233714 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -40,6 +40,10 @@ public class RecyclerTextEditor extends RecyclerView implements MarkorEditor { private final ArrayList _textChangedListeners = new ArrayList<>(); private final ArrayList _textWatcherListeners = new ArrayList<>(); private final ArrayList _matches = new ArrayList<>(); + private final RecyclerEditable _editorText = new RecyclerEditable(); + private int _textChangedNumber; + private final Runnable _textChangedRecorder = TextViewUtils.makeDebounced(1000, () -> _textChangedNumber++); + private final Runnable _highlightingDebounced = TextViewUtils.makeDebounced(220, this::recomputeHighlighting); private boolean _trailingNewline; private float _textSizeSp = 16f; @@ -48,6 +52,7 @@ public class RecyclerTextEditor extends RecyclerView implements MarkorEditor { private int _backgroundColor; private Typeface _typeface; private boolean _wrapEnabled = true; + private int _selectionStart; private int _selectionEnd; private InputFilter _autoFormatFilter; @@ -56,9 +61,9 @@ public class RecyclerTextEditor extends RecyclerView implements MarkorEditor { private SyntaxHighlighterBase _highlighter; private boolean _hlEnabled; private SyntaxHighlighterBase.SpanGroup _searchSelection; - private int _textChangedNumber; - private final Runnable _textChangedRecorder = TextViewUtils.makeDebounced(getHandler(), 1000, () -> _textChangedNumber++); private boolean _saveInstanceState = true; + private boolean _suppressEditorTextCallback; + private View.OnFocusChangeListener _externalFocusChangeListener; public RecyclerTextEditor(@NonNull Context context) { this(context, null); @@ -70,34 +75,16 @@ public RecyclerTextEditor(@NonNull Context context, @Nullable AttributeSet attrs setAdapter(_adapter); setItemAnimator(null); _lines.add(""); - _selectionStart = 0; - _selectionEnd = 0; + syncEditorTextFromLines(); } @Override public void setText(@Nullable CharSequence text) { - _lines.clear(); - _trailingNewline = false; - final String content = text != null ? text.toString() : ""; - final int len = content.length(); - int start = 0; - for (int i = 0; i < len; i++) { - if (content.charAt(i) == '\n') { - _lines.add(content.substring(start, i)); - start = i + 1; - } - } - if (start < len) { - _lines.add(content.substring(start)); - } else { - _trailingNewline = len > 0; - } - - if (_lines.isEmpty()) { - _lines.add(""); + parseToLines(text != null ? text.toString() : ""); + syncEditorTextFromLines(); + if (_hlEnabled) { + _highlightingDebounced.run(); } - _selectionStart = clampToLength(_selectionStart); - _selectionEnd = clampToLength(_selectionEnd); _adapter.notifyDataSetChanged(); applySelectionToVisibleLineEditor(); } @@ -105,35 +92,12 @@ public void setText(@Nullable CharSequence text) { @NonNull @Override public Editable getText() { - final StringBuilder sb = new StringBuilder(); - for (int i = 0; i < _lines.size(); i++) { - if (i > 0) { - sb.append('\n'); - } - sb.append(_lines.get(i)); - } - if (_trailingNewline && !_lines.isEmpty()) { - sb.append('\n'); - } - final SpannableStringBuilder fullText = new SpannableStringBuilder(sb); - final int selStart = clampToLength(_selectionStart); - final int selEnd = clampToLength(_selectionEnd); - if (GsTextUtils.inRange(0, fullText.length(), selStart, selEnd)) { - Selection.setSelection(fullText, selStart, selEnd); - } - return fullText; + return _editorText; } @Override public int length() { - int total = _trailingNewline && !_lines.isEmpty() ? 1 : 0; - for (int i = 0; i < _lines.size(); i++) { - total += _lines.get(i).length(); - if (i > 0) { - total += 1; - } - } - return total; + return _editorText.length(); } @Override @@ -178,14 +142,6 @@ public void addTextChangedListener(@Nullable Runnable listener) { } } - private void notifyTextChanged() { - for (Runnable listener : _textChangedListeners) { - if (listener != null) { - listener.run(); - } - } - } - @Override public void addTextChangedListener(@NonNull TextWatcher watcher) { if (!_textWatcherListeners.contains(watcher)) { @@ -203,6 +159,7 @@ public void setSelection(int index) { if (indexesValid(index)) { _selectionStart = index; _selectionEnd = index; + Selection.setSelection(_editorText, index); applySelectionToVisibleLineEditor(); } } @@ -212,61 +169,66 @@ public void setSelection(int start, int stop) { if (indexesValid(start, stop)) { _selectionStart = start; _selectionEnd = stop; - applySelectionToVisibleLineEditor(); } else if (indexesValid(start, stop - 1)) { _selectionStart = start; _selectionEnd = stop - 1; - applySelectionToVisibleLineEditor(); } else if (indexesValid(start + 1, stop)) { _selectionStart = start + 1; _selectionEnd = stop; - applySelectionToVisibleLineEditor(); + } else { + return; } + Selection.setSelection(_editorText, _selectionStart, _selectionEnd); + applySelectionToVisibleLineEditor(); } @Override public int getSelectionStart() { - return _selectionStart; + final int sel = Selection.getSelectionStart(_editorText); + return sel >= 0 ? sel : _selectionStart; } @Override public int getSelectionEnd() { - return _selectionEnd; + final int sel = Selection.getSelectionEnd(_editorText); + return sel >= 0 ? sel : _selectionEnd; } @Override public boolean hasSelection() { - return _selectionStart != _selectionEnd; + return getSelectionStart() != getSelectionEnd(); } @Override public void selectLines() { - final int[] lineSelection = TextViewUtils.getLineSelection(getText()); + final int[] lineSelection = TextViewUtils.getLineSelection(_editorText); setSelection(lineSelection[0], lineSelection[1]); } @Override public void insertOrReplaceTextOnCursor(String newText) { - final Editable editable = getText(); - if (editable == null || newText == null) { + final Editable edit = getText(); + if (newText == null) { return; } - final int selStart = Math.min(_selectionStart, _selectionEnd); - final int selEnd = Math.max(_selectionStart, _selectionEnd); - final CharSequence selected = editable.subSequence(selStart, selEnd); - String expanded = newText.replace(INSERT_SELECTION_HERE_TOKEN, selected); + final int[] sel = TextViewUtils.getSelection(edit); + final CharSequence selected = TextViewUtils.toString(edit, Math.max(sel[0], 0), Math.max(sel[1], 0)); + final String expanded = newText.replace(INSERT_SELECTION_HERE_TOKEN, selected); final int newCursorPos = expanded.indexOf(PLACE_CURSOR_HERE_TOKEN); final String finalText = expanded.replace(PLACE_CURSOR_HERE_TOKEN, ""); - editable.replace(selStart, selEnd, finalText); - setText(editable); + final int start = Math.max(sel[0], 0); + final int end = Math.max(sel[1], start); + if (newCursorPos >= 0) { + setSelection(start); + } + + withAutoFormatDisabled(() -> edit.replace(start, end, finalText)); if (newCursorPos >= 0) { - setSelection(selStart + newCursorPos); - } else { - setSelection(selStart + finalText.length()); + setSelection(start + newCursorPos); } } @@ -309,9 +271,13 @@ public void withAutoFormatDisabled(@NonNull GsCallback.a0 callback) { @Override public void setHighlighter(@Nullable SyntaxHighlighterBase highlighter) { + if (_highlighter != null) { + _highlighter.clearDynamic().clearStatic(true); + } _highlighter = highlighter; if (_hlEnabled) { - recomputeHighlighting(); + initHighlighter(); + _highlightingDebounced.run(); } } @@ -327,6 +293,10 @@ public boolean setHighlightingEnabled(boolean enable) { _hlEnabled = enable; if (_hlEnabled) { initHighlighter(); + _highlightingDebounced.run(); + } else if (_highlighter != null) { + _highlighter.clearDynamic().clearStatic(true).clearComputed(); + _adapter.notifyDataSetChanged(); } return previous; } @@ -338,15 +308,27 @@ public boolean getHighlightingEnabled() { @Override public void recomputeHighlighting() { - if (_hlEnabled && _highlighter != null) { - _highlighter.setSpannable(getText()).configure(); + if (!_hlEnabled || _highlighter == null) { + return; } + _highlighter + .setSpannable(_editorText) + .configure() + .clearDynamic() + .clearStatic(false) + .recompute() + .addAdditional(_matches); + if (_searchSelection != null) { + _highlighter.addAdditional(_searchSelection); + } + _highlighter.applyStatic(); + _adapter.notifyDataSetChanged(); } @Override public void initHighlighter() { if (_highlighter != null) { - _highlighter.setSpannable(getText()).configure(); + _highlighter.setSpannable(_editorText).configure(); } } @@ -356,30 +338,48 @@ public void setSearchMatches(@Nullable List spa if (spanGroups != null) { _matches.addAll(spanGroups); } + if (_hlEnabled) { + _highlightingDebounced.run(); + } } @Override public void removeSearchMatch(@Nullable SyntaxHighlighterBase.SpanGroup spanGroup) { _matches.remove(spanGroup); + if (_hlEnabled) { + _highlightingDebounced.run(); + } } @Override public void clearSearchMatches() { _matches.clear(); + if (_hlEnabled) { + _highlightingDebounced.run(); + } } @Override public void applyDynamicHighlight() { + if (_hlEnabled) { + _highlightingDebounced.run(); + } } @Override public void addSearchSelection(int start, int end, @ColorInt int color) { _searchSelection = SyntaxHighlighterBase.createBackgroundHighlight(start, end, color); + if (_hlEnabled) { + _highlightingDebounced.run(); + } } @Override public void clearSearchSelection() { _searchSelection = null; + if (_hlEnabled) { + _highlightingDebounced.run(); + } } @NonNull @@ -390,6 +390,7 @@ public View getView() { @Override public void setOnFocusChangeListener(@Nullable OnFocusChangeListener listener) { + _externalFocusChangeListener = listener; super.setOnFocusChangeListener(listener); } @@ -410,7 +411,7 @@ public boolean indexesValid(int... indexes) { @Override public int moveCursorToEndOfLine(int offset) { - final int[] lineCol = globalOffsetToLineCol(_selectionEnd); + final int[] lineCol = globalOffsetToLineCol(getSelectionEnd()); final int lineEnd = lineColToGlobalOffset(lineCol[0], _lines.get(lineCol[0]).length()); final int newPos = clampToLength(lineEnd + offset); setSelection(newPos); @@ -419,17 +420,83 @@ public int moveCursorToEndOfLine(int offset) { @Override public int moveCursorToBeginOfLine(int offset) { - final int[] lineCol = globalOffsetToLineCol(_selectionEnd); + final int[] lineCol = globalOffsetToLineCol(getSelectionEnd()); final int lineStart = lineColToGlobalOffset(lineCol[0], 0); final int newPos = clampToLength(lineStart + offset); setSelection(newPos); return getSelectionStart(); } - /** - * Convert global character offset to (lineIndex, columnInLine). - * Returns int[]{lineIndex, column}. If offset is past end, clamps to end. - */ + private void parseToLines(@NonNull String content) { + _lines.clear(); + _trailingNewline = false; + + final int len = content.length(); + int start = 0; + for (int i = 0; i < len; i++) { + if (content.charAt(i) == '\n') { + _lines.add(content.substring(start, i)); + start = i + 1; + } + } + if (start < len) { + _lines.add(content.substring(start)); + } else { + _trailingNewline = len > 0; + } + if (_lines.isEmpty()) { + _lines.add(""); + } + } + + @NonNull + private String buildFullTextFromLines() { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < _lines.size(); i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(_lines.get(i)); + } + if (_trailingNewline && !_lines.isEmpty()) { + sb.append('\n'); + } + return sb.toString(); + } + + private void syncEditorTextFromLines() { + _suppressEditorTextCallback = true; + _editorText.replace(0, _editorText.length(), buildFullTextFromLines()); + _selectionStart = clampToLength(_selectionStart); + _selectionEnd = clampToLength(_selectionEnd); + Selection.setSelection(_editorText, _selectionStart, _selectionEnd); + _suppressEditorTextCallback = false; + } + + private void syncFromEditorText() { + parseToLines(_editorText.toString()); + _selectionStart = clampToLength(getSelectionStart()); + _selectionEnd = clampToLength(getSelectionEnd()); + _adapter.notifyDataSetChanged(); + applySelectionToVisibleLineEditor(); + } + + private void onEditorTextMutated() { + if (_suppressEditorTextCallback) { + return; + } + syncFromEditorText(); + _textChangedRecorder.run(); + notifyTextChanged(); + if (_hlEnabled) { + _highlightingDebounced.run(); + } + } + + private int clampToLength(int value) { + return Math.max(0, Math.min(value, _editorText.length())); + } + private int[] globalOffsetToLineCol(int offset) { if (_lines.isEmpty()) { return new int[]{0, 0}; @@ -458,9 +525,6 @@ private int[] globalOffsetToLineCol(int offset) { return new int[]{lastIndex, _lines.get(lastIndex).length()}; } - /** - * Convert (lineIndex, column) to global character offset. - */ private int lineColToGlobalOffset(int lineIndex, int column) { if (_lines.isEmpty()) { return 0; @@ -472,19 +536,28 @@ private int lineColToGlobalOffset(int lineIndex, int column) { offset += _lines.get(i).length() + 1; } final int safeCol = Math.max(0, Math.min(column, _lines.get(safeLine).length())); - return Math.max(0, Math.min(offset + safeCol, length())); + return Math.max(0, Math.min(offset + safeCol, _editorText.length())); } - private int clampToLength(int value) { - return Math.max(0, Math.min(value, length())); + private int lineStartOffset(int lineIndex) { + int start = 0; + for (int i = 0; i < lineIndex; i++) { + start += _lines.get(i).length() + 1; + } + return start; } private void updateSelectionFromLineEditor(int lineIndex, int localStart, int localEnd) { _selectionStart = lineColToGlobalOffset(lineIndex, localStart); _selectionEnd = lineColToGlobalOffset(lineIndex, localEnd); + Selection.setSelection(_editorText, _selectionStart, _selectionEnd); } private void applySelectionToVisibleLineEditor() { + if (_lines.isEmpty()) { + return; + } + final int selStart = clampToLength(_selectionStart); final int selEnd = clampToLength(_selectionEnd); final int[] startLineCol = globalOffsetToLineCol(selStart); @@ -521,6 +594,27 @@ private AppCompatEditText findFocusedLineEditor() { return null; } + @NonNull + private CharSequence getLineDisplayText(int position) { + if (!_hlEnabled || _highlighter == null) { + return _lines.get(position); + } + final int start = lineStartOffset(position); + final int end = Math.min(start + _lines.get(position).length(), _editorText.length()); + if (start <= end) { + return _editorText.subSequence(start, end); + } + return _lines.get(position); + } + + private void notifyTextChanged() { + for (Runnable listener : _textChangedListeners) { + if (listener != null) { + listener.run(); + } + } + } + private void dispatchBeforeTextChanged(CharSequence s, int start, int count, int after) { for (TextWatcher watcher : new ArrayList<>(_textWatcherListeners)) { watcher.beforeTextChanged(s, start, count, after); @@ -539,6 +633,16 @@ private void dispatchAfterTextChanged(Editable s) { } } + private final class RecyclerEditable extends SpannableStringBuilder { + @NonNull + @Override + public SpannableStringBuilder replace(int start, int end, CharSequence tb, int tbstart, int tbend) { + super.replace(start, end, tb, tbstart, tbend); + onEditorTextMutated(); + return this; + } + } + private final class LinesAdapter extends RecyclerView.Adapter { @NonNull @Override @@ -591,7 +695,7 @@ private void bind(int position) { } _edit.setTag(position); - _edit.setText(_lines.get(position)); + _edit.setText(getLineDisplayText(position)); _edit.setTextSize(TypedValue.COMPLEX_UNIT_SP, _textSizeSp); _edit.setTypeface(_typeface); _edit.setTextColor(_textColor); @@ -602,6 +706,9 @@ private void bind(int position) { if (hasFocus) { updateSelectionFromLineEditor(position, _edit.getSelectionStart(), _edit.getSelectionEnd()); } + if (_externalFocusChangeListener != null) { + _externalFocusChangeListener.onFocusChange(RecyclerTextEditor.this, hasFocus); + } }); _watcher = new TextWatcher() { @@ -614,7 +721,7 @@ public void beforeTextChanged(CharSequence s, int start, int count, int after) { return; } _globalStart = lineColToGlobalOffset(pos, start); - dispatchBeforeTextChanged(getText(), _globalStart, count, after); + dispatchBeforeTextChanged(_editorText, _globalStart, count, after); } @Override @@ -624,18 +731,23 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { return; } _lines.set(pos, s != null ? s.toString() : ""); - dispatchOnTextChanged(getText(), _globalStart, before, count); + dispatchOnTextChanged(_editorText, _globalStart, before, count); } @Override public void afterTextChanged(Editable s) { final int pos = getBindingAdapterPosition(); - if (pos != NO_POSITION) { - _lines.set(pos, s != null ? s.toString() : ""); - updateSelectionFromLineEditor(pos, _edit.getSelectionStart(), _edit.getSelectionEnd()); - dispatchAfterTextChanged(getText()); - _textChangedRecorder.run(); - notifyTextChanged(); + if (pos == NO_POSITION) { + return; + } + _lines.set(pos, s != null ? s.toString() : ""); + updateSelectionFromLineEditor(pos, _edit.getSelectionStart(), _edit.getSelectionEnd()); + syncEditorTextFromLines(); + dispatchAfterTextChanged(_editorText); + _textChangedRecorder.run(); + notifyTextChanged(); + if (_hlEnabled) { + _highlightingDebounced.run(); } } }; From 5d397a81b85ba655f8a075f41ced7df0fb859785 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Tue, 17 Mar 2026 12:19:36 +0100 Subject: [PATCH 09/18] fix: cursor jumping or loss of focus after typing for a second in RecyclerTextEditor The cursor/focus drift came from recycler highlighting refresh rebinding rows and writing selection back during programmatic setText RecyclerTextEditor now suppresses selection sync during programmatic row text rebinding and no longer full-rebinds on each highlight recompute; it refreshes visible non-focused rows only. Changes are in app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java. Signed-off-by: Stephen L. Using OpenCode and Oh My OpenCode with ChatGPT Codex-5.3 and ultrawork mode --- .../frontend/textview/RecyclerTextEditor.java | 63 ++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java index 9da3233714..b30bfa63d4 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -63,6 +63,7 @@ public class RecyclerTextEditor extends RecyclerView implements MarkorEditor { private SyntaxHighlighterBase.SpanGroup _searchSelection; private boolean _saveInstanceState = true; private boolean _suppressEditorTextCallback; + private boolean _suppressSelectionSync; private View.OnFocusChangeListener _externalFocusChangeListener; public RecyclerTextEditor(@NonNull Context context) { @@ -322,7 +323,7 @@ public void recomputeHighlighting() { _highlighter.addAdditional(_searchSelection); } _highlighter.applyStatic(); - _adapter.notifyDataSetChanged(); + refreshVisibleLineEditors(false); } @Override @@ -594,6 +595,32 @@ private AppCompatEditText findFocusedLineEditor() { return null; } + private void refreshVisibleLineEditors(boolean includeFocused) { + final LayoutManager lm = getLayoutManager(); + if (!(lm instanceof LinearLayoutManager)) { + return; + } + + final LinearLayoutManager llm = (LinearLayoutManager) lm; + final int first = llm.findFirstVisibleItemPosition(); + final int last = llm.findLastVisibleItemPosition(); + if (first == NO_POSITION || last == NO_POSITION || first > last) { + return; + } + + final View focused = findFocus(); + for (int pos = first; pos <= last; pos++) { + final LineViewHolder holder = (LineViewHolder) findViewHolderForAdapterPosition(pos); + if (holder == null) { + continue; + } + if (!includeFocused && focused != null && focused == holder._edit) { + continue; + } + holder.rebindDisplayOnly(pos); + } + } + @NonNull private CharSequence getLineDisplayText(int position) { if (!_hlEnabled || _highlighter == null) { @@ -651,6 +678,9 @@ public LineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType @Override protected void onSelectionChanged(int selStart, int selEnd) { super.onSelectionChanged(selStart, selEnd); + if (_suppressSelectionSync) { + return; + } final Object tag = getTag(); if (tag instanceof Integer) { updateSelectionFromLineEditor((Integer) tag, selStart, selEnd); @@ -695,7 +725,12 @@ private void bind(int position) { } _edit.setTag(position); - _edit.setText(getLineDisplayText(position)); + _suppressSelectionSync = true; + try { + _edit.setText(getLineDisplayText(position)); + } finally { + _suppressSelectionSync = false; + } _edit.setTextSize(TypedValue.COMPLEX_UNIT_SP, _textSizeSp); _edit.setTypeface(_typeface); _edit.setTextColor(_textColor); @@ -758,5 +793,29 @@ public void afterTextChanged(Editable s) { _edit.setSelection(selectionLineCol[1]); } } + + private void rebindDisplayOnly(int position) { + if (_watcher != null) { + _edit.removeTextChangedListener(_watcher); + } + final int selStart = _edit.getSelectionStart(); + final int selEnd = _edit.getSelectionEnd(); + _edit.setTag(position); + _suppressSelectionSync = true; + try { + _edit.setText(getLineDisplayText(position)); + } finally { + _suppressSelectionSync = false; + } + if (_edit.hasFocus()) { + final int len = _edit.length(); + final int start = Math.max(0, Math.min(selStart, len)); + final int end = Math.max(0, Math.min(selEnd, len)); + _edit.setSelection(Math.min(start, end), Math.max(start, end)); + } + if (_watcher != null) { + _edit.addTextChangedListener(_watcher); + } + } } } From e78017e01916312da2689c8c38a7e5e65e20f2b7 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Tue, 17 Mar 2026 03:47:45 +0100 Subject: [PATCH 10/18] fix: implement TodoTxt actions for the RecyclerTextEditor by removing assumptions that depended on the classical MarkorEditor Removed the remaining classic-view assumptions from todo actions so TodoTxtActionButtons no longer uses (EditText)/(TextView) _hlEditor.getView() in recycler mode. The changes are in app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtActionButtons.java (context/project long-press, title-click filtering, search, archive selection restore, selected-task extraction, due-date lookup), plus supporting MarkorEditor-safe overloads in app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java, a text+selection overload in app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtTask.java, and MarkorEditor line-selection support in app/src/main/java/net/gsantner/markor/frontend/textview/TextViewUtils.java. Signed-off-by: Stephen L. Using OpenCode with Oh My OpenCode and ChatGPT-Codex-5.3 in ultrawork mode --- .../format/todotxt/TodoTxtActionButtons.java | 20 ++++---- .../markor/format/todotxt/TodoTxtTask.java | 4 ++ .../markor/frontend/MarkorDialogFactory.java | 29 ++++++++++- .../frontend/textview/TextViewUtils.java | 51 +++++++++++++++++++ 4 files changed, 92 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtActionButtons.java b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtActionButtons.java index 076f83a0ca..683341ccc4 100644 --- a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtActionButtons.java +++ b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtActionButtons.java @@ -13,8 +13,6 @@ import android.content.DialogInterface; import android.os.Bundle; import android.text.Editable; -import android.widget.EditText; -import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.StringRes; @@ -76,7 +74,7 @@ protected int getFormatActionsKey() { @SuppressLint("NonConstantResourceId") @Override public boolean onActionClick(final @StringRes int action) { - final List selTasks = TodoTxtTask.getSelectedTasks((TextView) _hlEditor.getView()); + final List selTasks = TodoTxtTask.getSelectedTasks(_hlEditor.getText(), TextViewUtils.getSelection(_hlEditor.getText())); switch (action) { case R.string.abid_todotxt_toggle_done: { @@ -143,11 +141,11 @@ public boolean onActionLongClick(final @StringRes int action) { switch (action) { case R.string.abid_todotxt_add_context: { - MarkorDialogFactory.showSttKeySearchDialog(getActivity(), (EditText) _hlEditor.getView(), R.string.browse_by_context, true, true, TodoTxtFilter.TYPE.CONTEXT); + MarkorDialogFactory.showSttKeySearchDialog(getActivity(), _hlEditor, R.string.browse_by_context, true, true, TodoTxtFilter.TYPE.CONTEXT); return true; } case R.string.abid_todotxt_add_project: { - MarkorDialogFactory.showSttKeySearchDialog(getActivity(), (EditText) _hlEditor.getView(), R.string.browse_by_project, true, true, TodoTxtFilter.TYPE.PROJECT); + MarkorDialogFactory.showSttKeySearchDialog(getActivity(), _hlEditor, R.string.browse_by_project, true, true, TodoTxtFilter.TYPE.PROJECT); return true; } case R.string.abid_todotxt_sort_todo: { @@ -247,7 +245,7 @@ public void archiveDoneTasks() { if (new Document(doneFile).saveContent(getActivity(), doneContents.toString())) { final String tasksString = TodoTxtTask.tasksToString(keep); _hlEditor.setText(tasksString); - TextViewUtils.setSelectionFromOffsets((TextView) _hlEditor.getView(), offsets); + TextViewUtils.setSelectionFromOffsets(_hlEditor.getText(), offsets); } } _appSettings.setLastTodoDoneName(_document.path, doneName); @@ -256,13 +254,13 @@ public void archiveDoneTasks() { @Override public boolean runTitleClick() { - MarkorDialogFactory.showSttFilteringDialog(getActivity(), (EditText) _hlEditor.getView()); + MarkorDialogFactory.showSttFilteringDialog(getActivity(), _hlEditor); return true; } @Override public boolean onSearch() { - MarkorDialogFactory.showSttSearchDialog(getActivity(), (EditText) _hlEditor.getView()); + MarkorDialogFactory.showSttSearchDialog(getActivity(), _hlEditor); return true; } @@ -275,7 +273,7 @@ private void addRemoveItems( final TodoTxtTask additional = new TodoTxtTask(_appSettings.getTodotxtAdditionalContextsAndProjects()); all.addAll(keyGetter.callback(Collections.singletonList(additional))); - final Set current = new HashSet<>(keyGetter.callback(TodoTxtTask.getSelectedTasks((TextView) _hlEditor.getView()))); + final Set current = new HashSet<>(keyGetter.callback(TodoTxtTask.getSelectedTasks(_hlEditor.getText(), TextViewUtils.getSelection(_hlEditor.getText())))); final boolean append = _appSettings.isTodoAppendProConOnEndEnabled(); @@ -398,7 +396,7 @@ private void setDate() { private void setDueDate(final int offset) { - final String dueString = TodoTxtTask.getSelectedTasks((TextView) _hlEditor.getView()).get(0).getDueDate(); + final String dueString = TodoTxtTask.getSelectedTasks(_hlEditor.getText(), TextViewUtils.getSelection(_hlEditor.getText())).get(0).getDueDate(); Calendar initDate = parseDateString(dueString, Calendar.getInstance()); initDate.add(Calendar.DAY_OF_MONTH, (dueString == null || dueString.isEmpty()) ? offset : 0); @@ -516,4 +514,4 @@ public void onCreate(final Bundle savedInstanceState) { } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtTask.java b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtTask.java index 2f62431053..78a20424e5 100644 --- a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtTask.java +++ b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtTask.java @@ -82,6 +82,10 @@ public static List getSelectedTasks(final TextView view) { return getTasks(view.getText(), TextViewUtils.getSelection(view)); } + public static List getSelectedTasks(final CharSequence text, final int[] sel) { + return getTasks(text, sel); + } + public static List getAllTasks(final CharSequence text) { return getTasks(text, new int[]{0, text.length()}); } diff --git a/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java b/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java index 119c6b9af4..4f803766d9 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java +++ b/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java @@ -242,6 +242,10 @@ public static void showSttSortDialogue(Activity activity, final GsCallback.a2 options = new ArrayList<>(); @@ -335,6 +339,17 @@ public static void showSttKeySearchDialog( final boolean enableSearch, final boolean enableAnd, final TodoTxtFilter.TYPE queryType + ) { + showSttKeySearchDialog(activity, (MarkorEditor) text, title, enableSearch, enableAnd, queryType); + } + + public static void showSttKeySearchDialog( + final Activity activity, + final MarkorEditor text, + final int title, + final boolean enableSearch, + final boolean enableAnd, + final TodoTxtFilter.TYPE queryType ) { final DialogOptions dopt = baseConf(activity); @@ -438,6 +453,14 @@ public static DialogOptions makeSttLineSelectionDialog( final Activity activity, final EditText text, final GsCallback.b1 filter + ) { + return makeSttLineSelectionDialog(activity, (MarkorEditor) text, filter); + } + + public static DialogOptions makeSttLineSelectionDialog( + final Activity activity, + final MarkorEditor text, + final GsCallback.b1 filter ) { final AppSettings as = AppSettings.get(activity); final DialogOptions dopt = baseConf(activity); @@ -468,12 +491,16 @@ public static DialogOptions makeSttLineSelectionDialog( // Search dialog for todo.txt public static void showSttSearchDialog(final Activity activity, final EditText text) { + showSttSearchDialog(activity, (MarkorEditor) text); + } + + public static void showSttSearchDialog(final Activity activity, final MarkorEditor text) { final DialogOptions dopt = makeSttLineSelectionDialog(activity, text, t -> true); dopt.titleText = R.string.search_documents; dopt.neutralButtonText = R.string.replace; dopt.neutralButtonCallback2 = (dialog, searchText) -> { dialog.dismiss(); - SearchAndReplaceTextDialog.showSearchReplaceDialog(activity, text.getText(), searchText, TextViewUtils.getSelection(text)); + SearchAndReplaceTextDialog.showSearchReplaceDialog(activity, text.getText(), searchText, TextViewUtils.getSelection(text.getText())); }; GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); } 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 9036dc8159..29aa1b1b92 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 @@ -319,6 +319,57 @@ public static void selectLines(final EditText edit, final List position } } + public static void selectLines(final MarkorEditor editor, final List positions) { + if (editor == null || positions == null || positions.isEmpty()) { + return; + } + + if (!editor.hasFocus()) { + editor.requestFocus(); + } + + final Editable text = editor.getText(); + if (text == null) { + return; + } + + if (positions.size() == 1) { + final int pos = positions.get(0); + final int index = pos >= 0 ? TextViewUtils.getIndexFromLineOffset(text, pos, 0) : text.length(); + editor.setSelection(index); + if (editor.getView() instanceof TextView) { + showSelection((TextView) editor.getView()); + } + return; + } + + final TreeSet pSet = new TreeSet<>(positions); + final int selStart; + final int selEnd; + final int minLine = Collections.min(pSet); + final int maxLine = Collections.max(pSet); + if (maxLine - minLine == pSet.size() - 1) { + selStart = TextViewUtils.getLineStart(text, TextViewUtils.getIndexFromLineOffset(text, minLine, 0)); + selEnd = TextViewUtils.getIndexFromLineOffset(text, maxLine, 0); + } else { + final String[] lines = text.toString().split("\n"); + final List sel = new ArrayList<>(); + final List unsel = new ArrayList<>(); + for (int i = 0; i < lines.length; i++) { + (pSet.contains(i) ? sel : unsel).add(lines[i]); + } + sel.addAll(unsel); + final String newText = android.text.TextUtils.join("\n", sel); + editor.setText(newText); + selStart = 0; + selEnd = TextViewUtils.getIndexFromLineOffset(editor.getText(), positions.size() - 1, 0); + } + editor.setSelection(selStart, selEnd); + if (editor.getView() instanceof TextView) { + showSelection((TextView) editor.getView()); + } + } + public static void showSelection(final TextView text, Rect visible, final int start, final int end, int offsetY) { // Get view info // ------------------------------------------------------------ From 22962ee3d9fdf1dda70fb38d9aa3b27d1a689fb2 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Tue, 17 Mar 2026 04:34:33 +0100 Subject: [PATCH 11/18] fix: todo.txt actions with RecyclerTextEditor (but causes crash) Signed-off-by: Stephen L. --- .../activity/DocumentEditAndViewFragment.java | 2 +- .../frontend/textview/RecyclerTextEditor.java | 35 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 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 2a27f764a9..3f2e1f8f08 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java @@ -81,7 +81,7 @@ public class DocumentEditAndViewFragment extends MarkorBaseFragment implements F public static final String FRAGMENT_TAG = "DocumentEditAndViewFragment"; public static final String SAVESTATE_DOCUMENT = "DOCUMENT"; public static final String START_PREVIEW = "START_PREVIEW"; - private static final long RECYCLER_EDITOR_FILE_BYTES_THRESHOLD = 500L * 1024L; + private static final long RECYCLER_EDITOR_FILE_BYTES_THRESHOLD = 50L * 1024L; public static float VIEW_FONT_SCALE = 100f / 15.7f; diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java index b30bfa63d4..d59b7c2c8b 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -86,7 +86,7 @@ public void setText(@Nullable CharSequence text) { if (_hlEnabled) { _highlightingDebounced.run(); } - _adapter.notifyDataSetChanged(); + notifyDataSetChangedPreservingFocus(); applySelectionToVisibleLineEditor(); } @@ -245,8 +245,15 @@ public void simulateKeyPress(int keyEvent_KEYCODE_SOMETHING) { @Override public void setAutoFormatters(@Nullable InputFilter inputFilter, @Nullable TextWatcher modifier) { + final boolean enabled = _autoFormatEnabled; + if (enabled) { + setAutoFormatEnabled(false); + } _autoFormatFilter = inputFilter; _autoFormatModifier = modifier; + if (enabled) { + setAutoFormatEnabled(true); + } } @Override @@ -256,7 +263,15 @@ public boolean getAutoFormatEnabled() { @Override public void setAutoFormatEnabled(boolean enable) { + if (enable && !_autoFormatEnabled) { + if (_autoFormatModifier != null && !_textWatcherListeners.contains(_autoFormatModifier)) { + _textWatcherListeners.add(_autoFormatModifier); + } + } else if (!enable && _autoFormatEnabled) { + _textWatcherListeners.remove(_autoFormatModifier); + } _autoFormatEnabled = enable; + _adapter.notifyItemRangeChanged(0, _lines.size()); } @Override @@ -297,7 +312,7 @@ public boolean setHighlightingEnabled(boolean enable) { _highlightingDebounced.run(); } else if (_highlighter != null) { _highlighter.clearDynamic().clearStatic(true).clearComputed(); - _adapter.notifyDataSetChanged(); + notifyDataSetChangedPreservingFocus(); } return previous; } @@ -323,7 +338,7 @@ public void recomputeHighlighting() { _highlighter.addAdditional(_searchSelection); } _highlighter.applyStatic(); - refreshVisibleLineEditors(false); + notifyDataSetChangedPreservingFocus(); } @Override @@ -478,10 +493,18 @@ private void syncFromEditorText() { parseToLines(_editorText.toString()); _selectionStart = clampToLength(getSelectionStart()); _selectionEnd = clampToLength(getSelectionEnd()); - _adapter.notifyDataSetChanged(); + notifyDataSetChangedPreservingFocus(); applySelectionToVisibleLineEditor(); } + private void notifyDataSetChangedPreservingFocus() { + final boolean hadFocusedLineEditor = findFocusedLineEditor() != null; + _adapter.notifyDataSetChanged(); + if (hadFocusedLineEditor) { + post(this::applySelectionToVisibleLineEditor); + } + } + private void onEditorTextMutated() { if (_suppressEditorTextCallback) { return; @@ -737,6 +760,9 @@ private void bind(int position) { _edit.setBackgroundColor(_backgroundColor); _edit.setLineSpacing(0f, _lineSpacingMultiplier); _edit.setHorizontallyScrolling(!_wrapEnabled); + _edit.setFilters(_autoFormatEnabled && _autoFormatFilter != null + ? new InputFilter[]{_autoFormatFilter} + : new InputFilter[0]); _edit.setOnFocusChangeListener((v, hasFocus) -> { if (hasFocus) { updateSelectionFromLineEditor(position, _edit.getSelectionStart(), _edit.getSelectionEnd()); @@ -766,6 +792,7 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { return; } _lines.set(pos, s != null ? s.toString() : ""); + syncEditorTextFromLines(); dispatchOnTextChanged(_editorText, _globalStart, before, count); } From f0ba331287ba8798d3fe6719c3c06e158c1f5380 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Tue, 17 Mar 2026 12:50:01 +0100 Subject: [PATCH 12/18] fix(todotxt): add MarkorEditor-safe dialog overloads in MarkorDialogFactory Introduce MarkorEditor-based overloads for todo.txt dialog flows so RecyclerTextEditor can use the same filtering/search/key-selection features without relying on EditText-only APIs. This adds MarkorEditor variants of: - showSttFilteringDialog(...) - showSttKeySearchDialog(...) - makeSttLineSelectionDialog(...) - showSttSearchDialog(...) The new overloads preserve existing behavior (saved filters, advanced filtering, key aggregation, AND/ANY toggle, selection dialog rendering, and replace flow) while operating on editor text and selection through MarkorEditor abstractions. This change removes a key source of recycler-mode crashes and keeps classic EditText callers compatible through existing method signatures. Signed-off-by: Stephen L. Using OpenCode and Oh My OpenCode with ChatGPT Codex-5.3 and ultrawork mode --- .../markor/frontend/MarkorDialogFactory.java | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java b/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java index 4f803766d9..82d8e0061b 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java +++ b/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java @@ -319,6 +319,77 @@ public static void showSttFilteringDialog(final Activity activity, final MarkorE GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); } + public static void showSttFilteringDialog(final Activity activity, final MarkorEditor text) { + final DialogOptions dopt = baseConf(activity); + + final List options = new ArrayList<>(); + final List icons = new ArrayList<>(); + final List callbacks = new ArrayList<>(); + + options.add(activity.getString(R.string.priority)); + icons.add(R.drawable.ic_star_black_24dp); + callbacks.add(() -> showSttKeySearchDialog(activity, text, R.string.browse_by_priority, false, false, TodoTxtFilter.TYPE.PRIORITY)); + + options.add(activity.getString(R.string.due_date)); + icons.add(R.drawable.ic_date_range_black_24dp); + callbacks.add(() -> showSttKeySearchDialog(activity, text, R.string.browse_by_due_date, false, false, TodoTxtFilter.TYPE.DUE)); + + options.add(activity.getString(R.string.project)); + icons.add(R.drawable.ic_new_label_black_24dp); + callbacks.add(() -> showSttKeySearchDialog(activity, text, R.string.browse_by_project, true, true, TodoTxtFilter.TYPE.PROJECT)); + + options.add(activity.getString(R.string.context)); + icons.add(R.drawable.gs_email_sign_black_24dp); + callbacks.add(() -> showSttKeySearchDialog(activity, text, R.string.browse_by_context, true, true, TodoTxtFilter.TYPE.CONTEXT)); + + options.add(activity.getString(R.string.advanced_filtering)); + icons.add(R.drawable.ic_extension_black_24dp); + callbacks.add(() -> { + final DialogOptions dopt2 = makeSttLineSelectionDialog(activity, text, t -> true); + dopt2.titleText = R.string.advanced_filtering; + dopt2.messageText = Html.fromHtml(activity.getString(R.string.advanced_filtering_help)); + final String[] queryHolder = new String[1]; + dopt2.searchFunction = (query, line, index) -> { + queryHolder[0] = query; + return TodoTxtFilter.isMatchQuery(new TodoTxtTask(line), query); + }; + addSaveQuery(activity, dopt2, () -> queryHolder[0]); + GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt2); + }); + + final List> savedViews = TodoTxtFilter.loadSavedFilters(activity); + final List indices = GsCollectionUtils.range(savedViews.size()); + Collections.sort(indices, (a, b) -> savedViews.get(a).first.compareTo(savedViews.get(b).first)); + + for (final int i : indices) { + final String title = savedViews.get(i).first; + final String query = savedViews.get(i).second; + options.add(title); + icons.add(R.drawable.empty_blank); + callbacks.add(() -> { + final DialogOptions doptView = makeSttLineSelectionDialog(activity, text, t -> TodoTxtFilter.isMatchQuery(t, query)); + setQueryTitle(doptView, title, query); + + doptView.neutralButtonText = R.string.delete; + doptView.isSoftInputVisible = false; + doptView.neutralButtonCallback = viewDialog -> showConfirmDialog(activity, R.string.confirm_delete, title, null, () -> { + viewDialog.dismiss(); + TodoTxtFilter.deleteFilterIndex(activity, i); + }); + + GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, doptView); + }); + } + + dopt.data = options; + dopt.iconsForData = icons; + dopt.positionCallback = (posn) -> callbacks.get(posn.get(0)).callback(); + dopt.isSearchEnabled = false; + dopt.titleText = R.string.browse_todo; + + GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); + } + /** * Filter todos with specified keys. *

@@ -414,6 +485,64 @@ public static void showSttKeySearchDialog( GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); } + public static void showSttKeySearchDialog( + final Activity activity, + final MarkorEditor text, + final int title, + final boolean enableSearch, + final boolean enableAnd, + final TodoTxtFilter.TYPE queryType + ) { + final DialogOptions dopt = baseConf(activity); + + final List allTasks = TodoTxtTask.getAllTasks(text.getText()); + final List keys = TodoTxtFilter.getKeys(activity, allTasks, queryType); + + final List data = new ArrayList<>(); + final List hlData = new ArrayList<>(); + for (final TodoTxtFilter.SttFilterKey k : keys) { + final String opt = String.format("%s (%d)", k.key, k.count); + data.add(opt); + if (k.query == null) { + hlData.add(opt); + } + } + dopt.data = data; + dopt.highlightData = hlData; + + final boolean[] useAnd = {false}; + if (enableAnd) { + dopt.neutralButtonText = R.string.match_any; + dopt.neutralButtonCallback = (dialog) -> { + Button neutralButton; + if (dialog != null && (neutralButton = dialog.getButton(AlertDialog.BUTTON_NEUTRAL)) != null) { + useAnd[0] = !useAnd[0]; + neutralButton.setText(useAnd[0] ? R.string.match_all : R.string.match_any); + } + }; + } + + dopt.titleText = title; + dopt.isSearchEnabled = enableSearch; + dopt.searchHintText = R.string.search; + dopt.selectionMode = DialogOptions.SelectionMode.MULTIPLE; + + dopt.positionCallback = (keyIndices) -> { + final List queryKeys = new ArrayList<>(); + for (final Integer index : keyIndices) { + queryKeys.add(keys.get(index).query); + } + final String query = TodoTxtFilter.makeQuery(queryKeys, useAnd[0], queryType); + + final DialogOptions doptSel = makeSttLineSelectionDialog(activity, text, t -> TodoTxtFilter.isMatchQuery(t, query)); + setQueryTitle(doptSel, activity.getString(title), query); + addSaveQuery(activity, doptSel, () -> query); + + GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, doptSel); + }; + GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); + } + private static void setQueryTitle(final DialogOptions dopt, final String subTitle, final String query) { // Remove the actual title dopt.titleText = 0; @@ -489,6 +618,38 @@ public static DialogOptions makeSttLineSelectionDialog( return dopt; } + public static DialogOptions makeSttLineSelectionDialog( + final Activity activity, + final MarkorEditor text, + final GsCallback.b1 filter + ) { + final AppSettings as = AppSettings.get(activity); + final DialogOptions dopt = baseConf(activity); + final List allTasks = TodoTxtTask.getAllTasks(text.getText()); + final List lines = new ArrayList<>(); + final List lineIndices = new ArrayList<>(); + for (int i = 0; i < allTasks.size(); i++) { + if (filter.callback(allTasks.get(i))) { + lines.add(allTasks.get(i).getLine()); + lineIndices.add(i); + } + } + dopt.data = lines; + dopt.titleText = R.string.search; + dopt.dataFilter = "[^\\s]+"; + dopt.selectionMode = DialogOptions.SelectionMode.MULTIPLE; + dopt.highlighter = as.isHighlightingEnabled() ? getSttHighlighter(as) : null; + dopt.positionCallback = (posns) -> { + final List selIndices = new ArrayList<>(); + for (final Integer p : posns) { + selIndices.add(lineIndices.get(p)); + } + TextViewUtils.selectLines(text, selIndices); + }; + + return dopt; + } + // Search dialog for todo.txt public static void showSttSearchDialog(final Activity activity, final EditText text) { showSttSearchDialog(activity, (MarkorEditor) text); @@ -505,6 +666,17 @@ public static void showSttSearchDialog(final Activity activity, final MarkorEdit GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); } + public static void showSttSearchDialog(final Activity activity, final MarkorEditor text) { + final DialogOptions dopt = makeSttLineSelectionDialog(activity, text, t -> true); + dopt.titleText = R.string.search_documents; + dopt.neutralButtonText = R.string.replace; + dopt.neutralButtonCallback2 = (dialog, searchText) -> { + dialog.dismiss(); + SearchAndReplaceTextDialog.showSearchReplaceDialog(activity, text.getText(), searchText, TextViewUtils.getSelection(text.getText())); + }; + GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); + } + /** * Allow to choose between Hexcolor / foreground / background color, pass back stringid */ From f96c5241c0064deb4e3b75ad7b8a064fe9ca14a8 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Tue, 17 Mar 2026 13:16:50 +0100 Subject: [PATCH 13/18] fix: build crash because of duplicated code in MarkorDialogFactory.java Introduced by merging stashed changes across multiple parallel branches. Signed-off-by: Stephen L. --- .../markor/frontend/MarkorDialogFactory.java | 169 ------------------ 1 file changed, 169 deletions(-) diff --git a/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java b/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java index 82d8e0061b..77e7866ff4 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java +++ b/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java @@ -319,76 +319,6 @@ public static void showSttFilteringDialog(final Activity activity, final MarkorE GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); } - public static void showSttFilteringDialog(final Activity activity, final MarkorEditor text) { - final DialogOptions dopt = baseConf(activity); - - final List options = new ArrayList<>(); - final List icons = new ArrayList<>(); - final List callbacks = new ArrayList<>(); - - options.add(activity.getString(R.string.priority)); - icons.add(R.drawable.ic_star_black_24dp); - callbacks.add(() -> showSttKeySearchDialog(activity, text, R.string.browse_by_priority, false, false, TodoTxtFilter.TYPE.PRIORITY)); - - options.add(activity.getString(R.string.due_date)); - icons.add(R.drawable.ic_date_range_black_24dp); - callbacks.add(() -> showSttKeySearchDialog(activity, text, R.string.browse_by_due_date, false, false, TodoTxtFilter.TYPE.DUE)); - - options.add(activity.getString(R.string.project)); - icons.add(R.drawable.ic_new_label_black_24dp); - callbacks.add(() -> showSttKeySearchDialog(activity, text, R.string.browse_by_project, true, true, TodoTxtFilter.TYPE.PROJECT)); - - options.add(activity.getString(R.string.context)); - icons.add(R.drawable.gs_email_sign_black_24dp); - callbacks.add(() -> showSttKeySearchDialog(activity, text, R.string.browse_by_context, true, true, TodoTxtFilter.TYPE.CONTEXT)); - - options.add(activity.getString(R.string.advanced_filtering)); - icons.add(R.drawable.ic_extension_black_24dp); - callbacks.add(() -> { - final DialogOptions dopt2 = makeSttLineSelectionDialog(activity, text, t -> true); - dopt2.titleText = R.string.advanced_filtering; - dopt2.messageText = Html.fromHtml(activity.getString(R.string.advanced_filtering_help)); - final String[] queryHolder = new String[1]; - dopt2.searchFunction = (query, line, index) -> { - queryHolder[0] = query; - return TodoTxtFilter.isMatchQuery(new TodoTxtTask(line), query); - }; - addSaveQuery(activity, dopt2, () -> queryHolder[0]); - GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt2); - }); - - final List> savedViews = TodoTxtFilter.loadSavedFilters(activity); - final List indices = GsCollectionUtils.range(savedViews.size()); - Collections.sort(indices, (a, b) -> savedViews.get(a).first.compareTo(savedViews.get(b).first)); - - for (final int i : indices) { - final String title = savedViews.get(i).first; - final String query = savedViews.get(i).second; - options.add(title); - icons.add(R.drawable.empty_blank); - callbacks.add(() -> { - final DialogOptions doptView = makeSttLineSelectionDialog(activity, text, t -> TodoTxtFilter.isMatchQuery(t, query)); - setQueryTitle(doptView, title, query); - - doptView.neutralButtonText = R.string.delete; - doptView.isSoftInputVisible = false; - doptView.neutralButtonCallback = viewDialog -> showConfirmDialog(activity, R.string.confirm_delete, title, null, () -> { - viewDialog.dismiss(); - TodoTxtFilter.deleteFilterIndex(activity, i); - }); - - GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, doptView); - }); - } - - dopt.data = options; - dopt.iconsForData = icons; - dopt.positionCallback = (posn) -> callbacks.get(posn.get(0)).callback(); - dopt.isSearchEnabled = false; - dopt.titleText = R.string.browse_todo; - - GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); - } /** * Filter todos with specified keys. @@ -485,63 +415,6 @@ public static void showSttKeySearchDialog( GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); } - public static void showSttKeySearchDialog( - final Activity activity, - final MarkorEditor text, - final int title, - final boolean enableSearch, - final boolean enableAnd, - final TodoTxtFilter.TYPE queryType - ) { - final DialogOptions dopt = baseConf(activity); - - final List allTasks = TodoTxtTask.getAllTasks(text.getText()); - final List keys = TodoTxtFilter.getKeys(activity, allTasks, queryType); - - final List data = new ArrayList<>(); - final List hlData = new ArrayList<>(); - for (final TodoTxtFilter.SttFilterKey k : keys) { - final String opt = String.format("%s (%d)", k.key, k.count); - data.add(opt); - if (k.query == null) { - hlData.add(opt); - } - } - dopt.data = data; - dopt.highlightData = hlData; - - final boolean[] useAnd = {false}; - if (enableAnd) { - dopt.neutralButtonText = R.string.match_any; - dopt.neutralButtonCallback = (dialog) -> { - Button neutralButton; - if (dialog != null && (neutralButton = dialog.getButton(AlertDialog.BUTTON_NEUTRAL)) != null) { - useAnd[0] = !useAnd[0]; - neutralButton.setText(useAnd[0] ? R.string.match_all : R.string.match_any); - } - }; - } - - dopt.titleText = title; - dopt.isSearchEnabled = enableSearch; - dopt.searchHintText = R.string.search; - dopt.selectionMode = DialogOptions.SelectionMode.MULTIPLE; - - dopt.positionCallback = (keyIndices) -> { - final List queryKeys = new ArrayList<>(); - for (final Integer index : keyIndices) { - queryKeys.add(keys.get(index).query); - } - final String query = TodoTxtFilter.makeQuery(queryKeys, useAnd[0], queryType); - - final DialogOptions doptSel = makeSttLineSelectionDialog(activity, text, t -> TodoTxtFilter.isMatchQuery(t, query)); - setQueryTitle(doptSel, activity.getString(title), query); - addSaveQuery(activity, doptSel, () -> query); - - GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, doptSel); - }; - GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); - } private static void setQueryTitle(final DialogOptions dopt, final String subTitle, final String query) { // Remove the actual title @@ -618,37 +491,6 @@ public static DialogOptions makeSttLineSelectionDialog( return dopt; } - public static DialogOptions makeSttLineSelectionDialog( - final Activity activity, - final MarkorEditor text, - final GsCallback.b1 filter - ) { - final AppSettings as = AppSettings.get(activity); - final DialogOptions dopt = baseConf(activity); - final List allTasks = TodoTxtTask.getAllTasks(text.getText()); - final List lines = new ArrayList<>(); - final List lineIndices = new ArrayList<>(); - for (int i = 0; i < allTasks.size(); i++) { - if (filter.callback(allTasks.get(i))) { - lines.add(allTasks.get(i).getLine()); - lineIndices.add(i); - } - } - dopt.data = lines; - dopt.titleText = R.string.search; - dopt.dataFilter = "[^\\s]+"; - dopt.selectionMode = DialogOptions.SelectionMode.MULTIPLE; - dopt.highlighter = as.isHighlightingEnabled() ? getSttHighlighter(as) : null; - dopt.positionCallback = (posns) -> { - final List selIndices = new ArrayList<>(); - for (final Integer p : posns) { - selIndices.add(lineIndices.get(p)); - } - TextViewUtils.selectLines(text, selIndices); - }; - - return dopt; - } // Search dialog for todo.txt public static void showSttSearchDialog(final Activity activity, final EditText text) { @@ -666,17 +508,6 @@ public static void showSttSearchDialog(final Activity activity, final MarkorEdit GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); } - public static void showSttSearchDialog(final Activity activity, final MarkorEditor text) { - final DialogOptions dopt = makeSttLineSelectionDialog(activity, text, t -> true); - dopt.titleText = R.string.search_documents; - dopt.neutralButtonText = R.string.replace; - dopt.neutralButtonCallback2 = (dialog, searchText) -> { - dialog.dismiss(); - SearchAndReplaceTextDialog.showSearchReplaceDialog(activity, text.getText(), searchText, TextViewUtils.getSelection(text.getText())); - }; - GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); - } - /** * Allow to choose between Hexcolor / foreground / background color, pass back stringid */ From 1d97ed379db0be99d8afd7485cc8f6c22a62d15a Mon Sep 17 00:00:00 2001 From: Stephen Karl Larroque Date: Tue, 17 Mar 2026 17:47:48 +0100 Subject: [PATCH 14/18] fix: Fix crash in TodoTxtSyntaxHighlighter when loading todotxt in RecyclerTextEditor Merge pull request #1 from lrq3000/fix-todotxt-crash-6284688702615462401 * Added `getEditorPaint()` to `RecyclerTextEditor` to provide a properly configured `Paint` instance to the syntax highlighter. * Modified `TodoTxtSyntaxHighlighter.configure(Paint)` to initialize a new `Paint` object if `null` is passed, preventing `NullPointerException` when fetching font metrics. By lrq3000 Using Jules with Gemini 3.1 Pro Preview --- .../format/todotxt/TodoTxtSyntaxHighlighter.java | 4 ++++ .../markor/frontend/textview/RecyclerTextEditor.java | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtSyntaxHighlighter.java b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtSyntaxHighlighter.java index f0519da6f9..b482f08be5 100644 --- a/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtSyntaxHighlighter.java +++ b/app/src/main/java/net/gsantner/markor/format/todotxt/TodoTxtSyntaxHighlighter.java @@ -32,6 +32,10 @@ public TodoTxtSyntaxHighlighter(final AppSettings as) { public SyntaxHighlighterBase configure(Paint paint) { super.configure(paint); + if (paint == null) { + paint = new Paint(); + } + _delay = _appSettings.getHighlightingDelayTodoTxt(); final boolean dark = MarkorContextUtils.instance.isDarkModeEnabled(_appSettings.getContext()); _paragraphSpan = new ParagraphDividerSpan(paint, dark ? 0x44FFFFFF : 0xFFDDDDDD); diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java index d59b7c2c8b..3b80077671 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -8,6 +8,7 @@ package net.gsantner.markor.frontend.textview; import android.content.Context; +import android.graphics.Paint; import android.graphics.Typeface; import android.text.Editable; import android.text.InputFilter; @@ -329,7 +330,7 @@ public void recomputeHighlighting() { } _highlighter .setSpannable(_editorText) - .configure() + .configure(getEditorPaint()) .clearDynamic() .clearStatic(false) .recompute() @@ -344,10 +345,17 @@ public void recomputeHighlighting() { @Override public void initHighlighter() { if (_highlighter != null) { - _highlighter.setSpannable(_editorText).configure(); + _highlighter.setSpannable(_editorText).configure(getEditorPaint()); } } + private Paint getEditorPaint() { + final Paint paint = new Paint(); + paint.setTextSize(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, _textSizeSp, getResources().getDisplayMetrics())); + paint.setTypeface(_typeface); + return paint; + } + @Override public void setSearchMatches(@Nullable List spanGroups) { _matches.clear(); From e67575d14a8ea88b674e44cd7ec485ec1cb72489 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:29:11 +0000 Subject: [PATCH 15/18] fix: Fix cursor erratic jumping and new item creation bugs in RecyclerTextEditor * Replaced `notifyDataSetChangedPreservingFocus()` with `refreshVisibleLineEditors(false)` inside `recomputeHighlighting()`. This prevents the currently focused EditText from completely rebinding while the user is typing or composing text, preserving IME composing spans and thus fixing erratic cursor jumps when word corrections are offered. * Added a check for newlines (`\n`) in `LineViewHolder`'s `afterTextChanged` method. When a newline is detected, `syncFromEditorText()` is explicitly called. This ensures that the RecyclerTextEditor immediately parses and splits the single line into multiple lines when the `AutoTextFormatter` inserts list prefixes, resulting in the cursor being properly placed at the end of the newly created list item instead of appearing stuck on the current line. Co-authored-by: lrq3000 <1118942+lrq3000@users.noreply.github.com> --- .../markor/frontend/textview/RecyclerTextEditor.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java index 3b80077671..0b6681fa9b 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -339,7 +339,7 @@ public void recomputeHighlighting() { _highlighter.addAdditional(_searchSelection); } _highlighter.applyStatic(); - notifyDataSetChangedPreservingFocus(); + refreshVisibleLineEditors(false); } @Override @@ -810,10 +810,16 @@ public void afterTextChanged(Editable s) { if (pos == NO_POSITION) { return; } + final boolean hasNewline = s != null && s.toString().contains("\n"); _lines.set(pos, s != null ? s.toString() : ""); updateSelectionFromLineEditor(pos, _edit.getSelectionStart(), _edit.getSelectionEnd()); syncEditorTextFromLines(); dispatchAfterTextChanged(_editorText); + + if (hasNewline) { + syncFromEditorText(); + } + _textChangedRecorder.run(); notifyTextChanged(); if (_hlEnabled) { From 077aef4e30b5c69a189431695184f2524b1fb36d Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:03:50 +0000 Subject: [PATCH 16/18] fix: fix RecyclerTextEditor erratic cursor jump and stuck cursor on newline * Replaced `notifyDataSetChangedPreservingFocus()` with `refreshVisibleLineEditors(false)` inside `recomputeHighlighting()`. This stops the focused `EditText` from fully rebinding while typing, preserving active IME composing states and fixing the erratic cursor jump when spell check autocorrect is offered. * Added logic in `LineViewHolder.afterTextChanged` to manually split lines when a newline is inserted by `AutoTextFormatter` without forcing a full document sync or `notifyDataSetChanged()`. This successfully shifts the cursor to the end of the newly pushed string block and uses `notifyItemInserted()` to let the cursor reliably transition down seamlessly without locking up on the previous line. Co-authored-by: lrq3000 <1118942+lrq3000@users.noreply.github.com> --- .../frontend/textview/RecyclerTextEditor.java | 86 +++++++++++++++++-- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java index 0b6681fa9b..afbb6fcd3f 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -605,7 +605,12 @@ private void applySelectionToVisibleLineEditor() { final AppCompatEditText edit = holder._edit; if (!edit.hasFocus()) { + final View currentFocus = findFocus(); + if (currentFocus != null && currentFocus != edit) { + currentFocus.clearFocus(); + } edit.requestFocus(); + edit.post(edit::requestFocus); } if (startLineCol[0] == endLineCol[0]) { @@ -810,16 +815,83 @@ public void afterTextChanged(Editable s) { if (pos == NO_POSITION) { return; } - final boolean hasNewline = s != null && s.toString().contains("\n"); - _lines.set(pos, s != null ? s.toString() : ""); - updateSelectionFromLineEditor(pos, _edit.getSelectionStart(), _edit.getSelectionEnd()); - syncEditorTextFromLines(); - dispatchAfterTextChanged(_editorText); - if (hasNewline) { - syncFromEditorText(); + final String text = s != null ? s.toString() : ""; + final int newlineIdx = text.indexOf('\n'); + + if (newlineIdx >= 0) { + // Manually split the line to avoid a full-document sync on large files. + // This correctly maps the input filter's output to separate RecyclerView items. + // Read values BEFORE mutating _edit or _lines + final int oldSel = _edit.getSelectionStart(); + final int originalLength = _lines.get(pos).length(); + + final String firstPart = text.substring(0, newlineIdx); + final String secondPart = text.substring(newlineIdx + 1); + + // We must remove the listener while mutating the local text to prevent recursion + _edit.removeTextChangedListener(this); + _edit.setText(firstPart); + + _lines.set(pos, firstPart); + + // If it's a multiple-newline paste, we just let the remaining text be the next line + // and it will get processed by subsequent sync or formatting operations. + // The user typed a single newline most of the time. + _lines.add(pos + 1, secondPart); + + _adapter.notifyItemInserted(pos + 1); + + // Fix the selection for the new line + // The input filter may have appended auto-formatting prefixes to secondPart. + // Instead of hardcoding regexes, we just calculate the difference in length. + // The user originally typed "\n" (1 char). The difference between the + // original text length and the new text length tells us how many chars + // the AutoFormatter injected! + // Calculate cursor position dynamically without hardcoding formatting logic. + // If the user typed \n, the string they typed was 1 character long, but + // the InputFilter (AutoTextFormatter) might have generated a string like `\n* `. + // The Android InputConnection only moves the cursor by the length of the string typed (1). + // So `oldSel` is roughly `newlineIdx + 1`. We need to move the cursor PAST + // whatever characters the InputFilter auto-inserted! + int calculatedNewSel = Math.max(0, oldSel - newlineIdx - 1); + + // We can detect how many characters were auto-inserted by looking at the length delta. + // The original line was `_lines.get(pos)`. + // The user typed `\n` (1 char). + // If `text.length() > original_length + 1`, the AutoFormatter added `text.length() - original_length - 1` chars. + // Those characters are inserted immediately after the newline. + final int injectedChars = text.length() - originalLength - 1; + if (injectedChars > 0 && oldSel <= newlineIdx + 1) { + calculatedNewSel += injectedChars; + } + final int finalNewSel = calculatedNewSel; + + _edit.addTextChangedListener(this); + + post(() -> { + final LineViewHolder nextHolder = (LineViewHolder) findViewHolderForAdapterPosition(pos + 1); + if (nextHolder != null) { + _edit.clearFocus(); + nextHolder._edit.requestFocus(); + nextHolder._edit.setSelection(Math.min(finalNewSel, nextHolder._edit.length())); + } + }); + + syncEditorTextFromLines(); + dispatchAfterTextChanged(_editorText); + _textChangedRecorder.run(); + notifyTextChanged(); + if (_hlEnabled) { + _highlightingDebounced.run(); + } + return; } + _lines.set(pos, text); + updateSelectionFromLineEditor(pos, _edit.getSelectionStart(), _edit.getSelectionEnd()); + syncEditorTextFromLines(); + dispatchAfterTextChanged(_editorText); _textChangedRecorder.run(); notifyTextChanged(); if (_hlEnabled) { From ae41638d46ebf9fe0a7f366296479a38fba812fd Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Fri, 20 Mar 2026 00:26:53 +0100 Subject: [PATCH 17/18] fix(editor): refresh focused line highlights and harden newline split handling Refresh visible editors including the focused line after highlight recomputation to prevent stale spans while typing. Improve newline split behavior by using pre-edit line length for cursor offset calculation, falling back to full sync for multi-line edits, and preserving cursor handoff when the next row view holder is not yet available. This implements fixes for all the 3 issues detected by AI reviewers (coderabbitai and chatgpt-codex). Signed-off-by: Stephen L. Using OpenCode and Oh My OpenCode with ChatGPT Codex-5.3 and ultrawork mode + plan mode. --- .../frontend/textview/RecyclerTextEditor.java | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java index afbb6fcd3f..ccfce59ad7 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -339,7 +339,10 @@ public void recomputeHighlighting() { _highlighter.addAdditional(_searchSelection); } _highlighter.applyStatic(); - refreshVisibleLineEditors(false); + // Refresh every visible row, including the focused editor, so newly + // computed highlighting is reflected immediately without waiting for + // a focus change or full item rebind. + refreshVisibleLineEditors(true); } @Override @@ -787,6 +790,7 @@ private void bind(int position) { _watcher = new TextWatcher() { int _globalStart; + int _lineLengthBeforeChange; @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { @@ -794,6 +798,10 @@ public void beforeTextChanged(CharSequence s, int start, int count, int after) { if (pos == NO_POSITION) { return; } + // Capture pre-edit line length here, because onTextChanged() + // mutates _lines[pos] and the post-edit length would make + // injected prefix delta calculations inaccurate. + _lineLengthBeforeChange = s != null ? s.length() : 0; _globalStart = lineColToGlobalOffset(pos, start); dispatchBeforeTextChanged(_editorText, _globalStart, count, after); } @@ -820,11 +828,26 @@ public void afterTextChanged(Editable s) { final int newlineIdx = text.indexOf('\n'); if (newlineIdx >= 0) { + // The manual split path is intentionally limited to a + // single newline edit. Multi-line paste/replacement must + // use full resync to preserve one-row-per-line invariants. + if (text.indexOf('\n', newlineIdx + 1) >= 0) { + updateSelectionFromLineEditor(pos, _edit.getSelectionStart(), _edit.getSelectionEnd()); + syncFromEditorText(); + dispatchAfterTextChanged(_editorText); + _textChangedRecorder.run(); + notifyTextChanged(); + if (_hlEnabled) { + _highlightingDebounced.run(); + } + return; + } + // Manually split the line to avoid a full-document sync on large files. // This correctly maps the input filter's output to separate RecyclerView items. // Read values BEFORE mutating _edit or _lines final int oldSel = _edit.getSelectionStart(); - final int originalLength = _lines.get(pos).length(); + final int originalLength = _lineLengthBeforeChange; final String firstPart = text.substring(0, newlineIdx); final String secondPart = text.substring(newlineIdx + 1); @@ -857,7 +880,6 @@ public void afterTextChanged(Editable s) { int calculatedNewSel = Math.max(0, oldSel - newlineIdx - 1); // We can detect how many characters were auto-inserted by looking at the length delta. - // The original line was `_lines.get(pos)`. // The user typed `\n` (1 char). // If `text.length() > original_length + 1`, the AutoFormatter added `text.length() - original_length - 1` chars. // Those characters are inserted immediately after the newline. @@ -866,6 +888,13 @@ public void afterTextChanged(Editable s) { calculatedNewSel += injectedChars; } final int finalNewSel = calculatedNewSel; + // Convert the target cursor position (newly inserted line + local column) + // into the document-wide offset and store it as a collapsed selection. + // This lets selection restoration/cursor handoff work even if the next row + // view holder is not immediately available. + final int newGlobalSel = lineColToGlobalOffset(pos + 1, finalNewSel); + _selectionStart = newGlobalSel; + _selectionEnd = newGlobalSel; _edit.addTextChangedListener(this); @@ -875,6 +904,11 @@ public void afterTextChanged(Editable s) { _edit.clearFocus(); nextHolder._edit.requestFocus(); nextHolder._edit.setSelection(Math.min(finalNewSel, nextHolder._edit.length())); + } else { + // When RecyclerView has not laid out the inserted + // row yet, apply the global selection fallback so + // cursor handoff still lands on the new line. + applySelectionToVisibleLineEditor(); } }); From da6c17dd0fe94c9d72c94048d030087e12d0f5e5 Mon Sep 17 00:00:00 2001 From: "Stephen L." Date: Fri, 20 Mar 2026 01:10:47 +0100 Subject: [PATCH 18/18] fix(large-files-editor): fix backspace on empty line not deleting the line in RecyclerTextEditor Traced the issue to a missing key-event path: when a row is empty and cursor is at column 0, TextWatcher does not fire a text deletion, so line-removal logic never runs. I fixed this in RecyclerTextEditor.java:788 by adding a minimal KEYCODE_DEL handler in LineViewHolder.bind(...) that only triggers for Backspace at start-of-line on non-first rows, merges current line into previous line (generalizable behavior, including empty-line delete), updates adapter rows, recalculates global selection, syncs _editorText, notifies text watchers, and restores focus/cursor to the previous line with a fallback when holder is not yet laid out. Signed-off-by: Stephen L. Using OpenCode and Oh My OpenCode with ChatGPT Codex-5.3 and ultrawork mode --- .../frontend/textview/RecyclerTextEditor.java | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java index ccfce59ad7..9ef172d650 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/RecyclerTextEditor.java @@ -788,6 +788,63 @@ private void bind(int position) { } }); + _edit.setOnKeyListener((v, keyCode, event) -> { + if (keyCode != KeyEvent.KEYCODE_DEL || event.getAction() != KeyEvent.ACTION_DOWN) { + return false; + } + + final int pos = getBindingAdapterPosition(); + if (pos == NO_POSITION || pos <= 0) { + return false; + } + + if (_edit.getSelectionStart() != 0 || _edit.getSelectionEnd() != 0) { + return false; + } + + final String previousLine = _lines.get(pos - 1); + final String currentLine = _lines.get(pos); + final int previousLineLength = previousLine.length(); + final int deletedNewlineGlobalOffset = lineColToGlobalOffset(pos, 0) - 1; + + dispatchBeforeTextChanged(_editorText, deletedNewlineGlobalOffset, 1, 0); + + withAutoFormatDisabled(() -> { + _lines.set(pos - 1, previousLine + currentLine); + _lines.remove(pos); + + _adapter.notifyItemChanged(pos - 1); + _adapter.notifyItemRemoved(pos); + + final int newGlobalSel = lineColToGlobalOffset(pos - 1, previousLineLength); + _selectionStart = newGlobalSel; + _selectionEnd = newGlobalSel; + + syncEditorTextFromLines(); + }); + + dispatchOnTextChanged(_editorText, deletedNewlineGlobalOffset, 1, 0); + dispatchAfterTextChanged(_editorText); + _textChangedRecorder.run(); + notifyTextChanged(); + if (_hlEnabled) { + _highlightingDebounced.run(); + } + + post(() -> { + final LineViewHolder previousHolder = (LineViewHolder) findViewHolderForAdapterPosition(pos - 1); + if (previousHolder != null) { + _edit.clearFocus(); + previousHolder._edit.requestFocus(); + previousHolder._edit.setSelection(Math.min(previousLineLength, previousHolder._edit.length())); + } else { + applySelectionToVisibleLineEditor(); + } + }); + + return true; + }); + _watcher = new TextWatcher() { int _globalStart; int _lineLengthBeforeChange;