diff --git a/.github/workflows/build-android-project.yml b/.github/workflows/build-android-project.yml index 1639b0743..e4d7467e1 100644 --- a/.github/workflows/build-android-project.yml +++ b/.github/workflows/build-android-project.yml @@ -57,14 +57,14 @@ jobs: - name: "Artifacts: All" if: always() - uses: actions/upload-artifact@v2.2.1 + uses: actions/upload-artifact@v4 with: name: "all" path: dist retention-days: 5 - name: "Artifacts: Android APK" - uses: actions/upload-artifact@v2.2.1 + uses: actions/upload-artifact@v4 with: name: "android-apk" path: | diff --git a/README.md b/README.md index e312c24cc..d9c1aa2ec 100644 --- a/README.md +++ b/README.md @@ -255,7 +255,8 @@ Todo.txt is a simple text format for todo. Each line of text is a task. The idea | [User Documentation](https://github.com/todotxt/todo.txt-cli/wiki/User-Documentation) | User documentation | -![todotxt](doc/assets/todotxt-format.png) +![todotxt](doc/assets/todotxt-format-dark.png#gh-dark-mode-only) +![todotxt](doc/assets/todotxt-format.png#gh-light-mode-only) #### How to mark a task done? Done tasks are marked by a `x ` in begining of the line and can optionally be moved to a done/archive file. diff --git a/app/build.gradle b/app/build.gradle index 7ac929e0e..64f4bc03f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -138,9 +138,9 @@ dependencies { // UI libs - implementation 'com.pixplicity.generate:library:1.1.8' + implementation 'com.github.Pixplicity:gene-rate:v1.1.8' implementation 'com.github.AppIntro:AppIntro:6.2.0' - implementation 'com.kailashdabhi:om-recorder:1.1.5' + implementation 'com.github.kailash09dabhi:OmRecorder:1.1.5' implementation 'com.github.mertakdut:EpubParser:1.0.95' implementation 'com.github.martin-stone:hsv-alpha-color-picker-android:3.1.0' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ce51fec55..7f5d71a89 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,7 +62,7 @@ android:name=".activity.MainActivity" android:exported="true" android:label="@string/app_name" - android:launchMode="singleInstance" + android:launchMode="standard" android:taskAffinity=".activity.MainActivity" android:windowSoftInputMode="stateUnchanged|adjustResize"> @@ -167,7 +167,7 @@ = 0) { + if (lineNumber != null) { intent.putExtra(Document.EXTRA_FILE_LINE_NUMBER, lineNumber); } if (doPreview != null) { - intent.putExtra(DocumentActivity.EXTRA_DO_PREVIEW, doPreview); + intent.putExtra(Document.EXTRA_DO_PREVIEW, doPreview); } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP && as.isMultiWindowEnabled()) { intent.addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT | Intent.FLAG_ACTIVITY_MULTIPLE_TASK); - } else { - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); } nextLaunchTransparentBg = (activity instanceof MainActivity); @@ -190,7 +194,7 @@ private void handleLaunchingIntent(final Intent intent) { final Document doc = new Document(file); Integer startLine = null; if (intent.hasExtra(Document.EXTRA_FILE_LINE_NUMBER)) { - startLine = intent.getIntExtra(Document.EXTRA_FILE_LINE_NUMBER, -1); + startLine = intent.getIntExtra(Document.EXTRA_FILE_LINE_NUMBER, Document.EXTRA_FILE_LINE_NUMBER_LAST); } else if (intentData != null) { final String line = intentData.getQueryParameter("line"); if (line != null) { @@ -203,7 +207,7 @@ private void handleLaunchingIntent(final Intent intent) { if (startLine != null) { // If a line is requested, open in edit mode so the line is shown startInPreview = false; - } else if (intent.getBooleanExtra(EXTRA_DO_PREVIEW, false) || file.getName().startsWith("index.")) { + } else if (intent.getBooleanExtra(Document.EXTRA_DO_PREVIEW, false) || file.getName().startsWith("index.")) { startInPreview = true; } @@ -272,11 +276,11 @@ public void setDocumentTitle(final String title) { } public void showTextEditor(final Document document, final Integer lineNumber, final Boolean startPreview) { - final GsFragmentBase currentFragment = getCurrentVisibleFragment(); + final GsFragmentBase currentFragment = getCurrentVisibleFragment(); final boolean sameDocumentRequested = ( currentFragment instanceof DocumentEditAndViewFragment && - document.getPath().equals(((DocumentEditAndViewFragment) currentFragment).getDocument().getPath())); + document.path.equals(((DocumentEditAndViewFragment) currentFragment).getDocument().path)); if (!sameDocumentRequested) { showFragment(DocumentEditAndViewFragment.newInstance(document, lineNumber, startPreview)); @@ -297,21 +301,15 @@ protected void onResume() { @Override @SuppressWarnings("StatementWithEmptyBody") public void onBackPressed() { - FragmentManager fragMgr = getSupportFragmentManager(); - GsFragmentBase top = getCurrentVisibleFragment(); - if (top != null) { - if (!top.onBackPressed()) { - if (fragMgr.getBackStackEntryCount() == 1) { - // Back action was not handled by fragment, handle in activity - } else if (fragMgr.getBackStackEntryCount() > 0) { - // Back action was to go one fragment back - fragMgr.popBackStack(); - return; - } - } else { - // Was handled by child fragment - return; - } + final int entryCount = _fragManager.getBackStackEntryCount(); + final GsFragmentBase top = getCurrentVisibleFragment(); + + // We pop the stack to go back to the previous fragment + // if the top fragment does not handle the back press + // Doesn't actually get called as we have 1 fragment in the stack + if (top != null && !top.onBackPressed() && entryCount > 1) { + _fragManager.popBackStack(); + return; } // Handle in this activity @@ -324,10 +322,10 @@ public void onBackPressed() { @Override public boolean onKeyDown(int keyCode, KeyEvent event) { - return super.onReceiveKeyPress(getCurrentVisibleFragment(), keyCode, event) ? true : super.onKeyDown(keyCode, event); + return super.onReceiveKeyPress(getCurrentVisibleFragment(), keyCode, event) || super.onKeyDown(keyCode, event); } - public GsFragmentBase showFragment(GsFragmentBase fragment) { + public GsFragmentBase showFragment(GsFragmentBase fragment) { if (fragment != getCurrentVisibleFragment()) { _fragManager.beginTransaction() .replace(R.id.document__placeholder_fragment, fragment, fragment.getFragmentTag()) @@ -338,11 +336,11 @@ public GsFragmentBase showFragment(GsFragmentBase fragment) { return fragment; } - public synchronized GsFragmentBase getExistingFragment(final String fragmentTag) { - return (GsFragmentBase) getSupportFragmentManager().findFragmentByTag(fragmentTag); + public synchronized GsFragmentBase getExistingFragment(final String fragmentTag) { + return (GsFragmentBase) getSupportFragmentManager().findFragmentByTag(fragmentTag); } - private GsFragmentBase getCurrentVisibleFragment() { - return (GsFragmentBase) getSupportFragmentManager().findFragmentById(R.id.document__placeholder_fragment); + private GsFragmentBase getCurrentVisibleFragment() { + return (GsFragmentBase) getSupportFragmentManager().findFragmentById(R.id.document__placeholder_fragment); } } \ No newline at end of file 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 f154544f2..9aadba330 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentEditAndViewFragment.java @@ -168,6 +168,7 @@ public void onPageFinished(WebView v) { _webView.setWebChromeClient(new GsWebViewChromeClient(_webView, activity, view.findViewById(R.id.document__fragment_fullscreen_overlay))); _webView.setWebViewClient(_webViewClient); _webView.addJavascriptInterface(this, "Android"); + _webView.setBackgroundColor(Color.TRANSPARENT); WebSettings webSettings = _webView.getSettings(); webSettings.setBuiltInZoomControls(true); webSettings.setDisplayZoomControls(false); @@ -189,17 +190,15 @@ public void onPageFinished(WebView v) { // Upon construction, the document format has been determined from extension etc // Here we replace it with the last saved format. - _document.setFormat(_appSettings.getDocumentFormat(_document.getPath(), _document.getFormat())); - applyTextFormat(_document.getFormat()); - _format.getActions().setDocument(_document); + applyTextFormat(_appSettings.getDocumentFormat(_document.path, _document.getFormat())); if (activity instanceof DocumentActivity) { - ((DocumentActivity) activity).setDocumentTitle(_document.getTitle()); + ((DocumentActivity) activity).setDocumentTitle(_document.title); } // Preview mode set before loadDocument to prevent flicker final Bundle args = getArguments(); - final boolean startInPreview = _appSettings.getDocumentPreviewState(_document.getPath()); + final boolean startInPreview = _appSettings.getDocumentPreviewState(_document.path); if (args != null && savedInstanceState == null) { // Use the launch flag on first launch setViewModeVisibility(args.getBoolean(START_PREVIEW, startInPreview), false); } else { @@ -218,22 +217,21 @@ public void onPageFinished(WebView v) { // Configure the editor. Doing so after load helps prevent some errors // --------------------------------------------------------- _hlEditor.setLineSpacing(0, _appSettings.getEditorLineSpacing()); - _hlEditor.setTextSize(TypedValue.COMPLEX_UNIT_SP, _appSettings.getDocumentFontSize(_document.getPath())); + _hlEditor.setTextSize(TypedValue.COMPLEX_UNIT_SP, _appSettings.getDocumentFontSize(_document.path)); _hlEditor.setTypeface(GsFontPreferenceCompat.typeface(getContext(), _appSettings.getFontFamily(), Typeface.NORMAL)); _hlEditor.setBackgroundColor(_appSettings.getEditorBackgroundColor()); _hlEditor.setTextColor(_appSettings.getEditorForegroundColor()); _hlEditor.setGravity(_appSettings.isEditorStartEditingInCenter() ? Gravity.CENTER : Gravity.NO_GRAVITY); - _hlEditor.setHighlightingEnabled(_appSettings.getDocumentHighlightState(_document.getPath(), _hlEditor.getText())); - _hlEditor.setLineNumbersEnabled(_appSettings.getDocumentLineNumbersEnabled(_document.getPath())); - _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.getPath())); + _hlEditor.setHighlightingEnabled(_appSettings.getDocumentHighlightState(_document.path, _hlEditor.getText())); + _hlEditor.setLineNumbersEnabled(_appSettings.getDocumentLineNumbersEnabled(_document.path)); + _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.path)); 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); } - _webView.setBackgroundColor(Color.TRANSPARENT); // Various settings - setHorizontalScrollMode(isDisplayedAtMainActivity() || _appSettings.getDocumentWrapState(_document.getPath())); + setHorizontalScrollMode(isDisplayedAtMainActivity() || _appSettings.getDocumentWrapState(_document.path)); updateMenuToggleStates(0); // --------------------------------------------------------- @@ -267,23 +265,22 @@ public void onPageFinished(WebView v) { } @Override - public void onFragmentFirstTimeVisible() { - _primaryScrollView.invalidate(); - int startPos = _appSettings.getLastEditPosition(_document.getPath(), _hlEditor.length()); - - // First start - overwrite start position if needed - if (_savedInstanceState == null) { - final Bundle args = getArguments(); - if (args != null && args.containsKey(Document.EXTRA_FILE_LINE_NUMBER)) { - final int lno = args.getInt(Document.EXTRA_FILE_LINE_NUMBER); - if (lno >= 0) { - startPos = TextViewUtils.getIndexFromLineOffset(_hlEditor.getText(), lno, 0); - } else if (lno == Document.EXTRA_FILE_LINE_NUMBER_LAST) { - startPos = _hlEditor.length(); - } + protected void onFragmentFirstTimeVisible() { + final Bundle args = getArguments(); + + int startPos = _appSettings.getLastEditPosition(_document.path, _hlEditor.length()); + if (args != null && args.containsKey(Document.EXTRA_FILE_LINE_NUMBER)) { + final int lno = args.getInt(Document.EXTRA_FILE_LINE_NUMBER); + if (lno >= 0) { + startPos = TextViewUtils.getIndexFromLineOffset(_hlEditor.getText(), lno, 0); + } else if (lno == Document.EXTRA_FILE_LINE_NUMBER_LAST) { + startPos = _hlEditor.length(); } } + _primaryScrollView.invalidate(); + // Can affect layout so run before setting scroll position + _hlEditor.recomputeHighlighting(); TextViewUtils.setSelectionAndShow(_hlEditor, startPos); } @@ -298,9 +295,9 @@ public void onResume() { public void onPause() { saveDocument(false); _webView.onPause(); - _appSettings.addRecentFile(_document.getFile()); - _appSettings.setDocumentPreviewState(_document.getPath(), _isPreviewVisible); - _appSettings.setLastEditPosition(_document.getPath(), _hlEditor.getSelectionStart()); + _appSettings.addRecentFile(_document.file); + _appSettings.setDocumentPreviewState(_document.path, _isPreviewVisible); + _appSettings.setLastEditPosition(_document.path, TextViewUtils.getSelection(_hlEditor)[0]); super.onPause(); } @@ -455,8 +452,7 @@ public boolean loadDocument() { _editTextUndoRedoHelper.setTextView(_hlEditor); } - _hlEditor.setSelection(sel[0], sel[1]); - TextViewUtils.showSelection(_hlEditor); + TextViewUtils.setSelectionAndShow(_hlEditor, sel); } checkTextChangeState(); @@ -515,7 +511,7 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { return true; } case R.id.action_share_path: { - _cu.shareText(getActivity(), _document.getFile().getAbsolutePath(), GsContextUtils.MIME_TEXT_PLAIN); + _cu.shareText(getActivity(), _document.file.getAbsolutePath(), GsContextUtils.MIME_TEXT_PLAIN); return true; } case R.id.action_share_text: { @@ -526,7 +522,7 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { } case R.id.action_share_file: { if (saveDocument(false)) { - _cu.shareStream(getActivity(), _document.getFile(), GsContextUtils.MIME_TEXT_PLAIN); + _cu.shareStream(getActivity(), _document.file, GsContextUtils.MIME_TEXT_PLAIN); } return true; } @@ -535,7 +531,7 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (saveDocument(false)) { TextConverterBase converter = FormatRegistry.getFormat(_document.getFormat(), activity, _document).getConverter(); _cu.shareText(getActivity(), - converter.convertMarkup(getTextString(), getActivity(), false, _hlEditor.getLineNumbersEnabled(), _document.getFile()), + converter.convertMarkup(getTextString(), getActivity(), false, _hlEditor.getLineNumbersEnabled(), _document.file), "text/" + (item.getItemId() == R.id.action_share_html ? "html" : "plain") ); } @@ -543,7 +539,7 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { } case R.id.action_share_calendar_event: { if (saveDocument(false)) { - if (!_cu.createCalendarAppointment(getActivity(), _document.getTitle(), getTextString(), null)) { + if (!_cu.createCalendarAppointment(getActivity(), _document.title, getTextString(), null)) { Toast.makeText(activity, R.string.no_calendar_app_is_installed, Toast.LENGTH_SHORT).show(); } } @@ -580,7 +576,7 @@ public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (itemId != _document.getFormat()) { _document.setFormat(itemId); applyTextFormat(itemId); - _appSettings.setDocumentFormat(_document.getPath(), _document.getFormat()); + _appSettings.setDocumentFormat(_document.path, _document.getFormat()); } return true; } @@ -616,14 +612,14 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { } case R.id.action_wrap_words: { final boolean newState = !isWrapped(); - _appSettings.setDocumentWrapState(_document.getPath(), newState); + _appSettings.setDocumentWrapState(_document.path, newState); setHorizontalScrollMode(newState); updateMenuToggleStates(0); return true; } case R.id.action_line_numbers: { final boolean newState = !_hlEditor.getLineNumbersEnabled(); - _appSettings.setDocumentLineNumbersEnabled(_document.getPath(), newState); + _appSettings.setDocumentLineNumbersEnabled(_document.path, newState); _hlEditor.setLineNumbersEnabled(newState); updateMenuToggleStates(0); return true; @@ -631,34 +627,34 @@ public void onFsViewerConfig(GsFileBrowserOptions.Options dopt) { case R.id.action_enable_highlighting: { final boolean newState = !_hlEditor.getHighlightingEnabled(); _hlEditor.setHighlightingEnabled(newState); - _appSettings.setDocumentHighlightState(_document.getPath(), newState); + _appSettings.setDocumentHighlightState(_document.path, newState); updateMenuToggleStates(0); return true; } case R.id.action_enable_auto_format: { final boolean newState = !_hlEditor.getAutoFormatEnabled(); _hlEditor.setAutoFormatEnabled(newState); - _appSettings.setDocumentAutoFormatEnabled(_document.getPath(), newState); + _appSettings.setDocumentAutoFormatEnabled(_document.path, newState); updateMenuToggleStates(0); return true; } case R.id.action_info: { if (saveDocument(false)) { // In order to have the correct info displayed - FileInfoDialog.show(_document.getFile(), getParentFragmentManager()); + FileInfoDialog.show(_document.file, getParentFragmentManager()); } return true; } case R.id.action_set_font_size: { - MarkorDialogFactory.showFontSizeDialog(activity, _appSettings.getDocumentFontSize(_document.getPath()), (newSize) -> { + MarkorDialogFactory.showFontSizeDialog(activity, _appSettings.getDocumentFontSize(_document.path), (newSize) -> { _hlEditor.setTextSize(TypedValue.COMPLEX_UNIT_SP, (float) newSize); - _appSettings.setDocumentFontSize(_document.getPath(), newSize); + _appSettings.setDocumentFontSize(_document.path, newSize); }); return true; } case R.id.action_show_file_browser: { // Delay because I want menu to close before we open the file browser _hlEditor.postDelayed(() -> { - final Intent intent = new Intent(activity, MainActivity.class).putExtra(Document.EXTRA_FILE, _document.getFile()); + final Intent intent = new Intent(activity, MainActivity.class).putExtra(Document.EXTRA_FILE, _document.file); GsContextUtils.instance.animateToActivity(activity, intent, false, null); }, 250); return true; @@ -678,6 +674,7 @@ public void checkTextChangeState() { } } + @Override public void applyTextFormat(final int textFormatId) { final Activity activity = getActivity(); if (activity == null) { @@ -688,8 +685,9 @@ public void applyTextFormat(final int textFormatId) { _hlEditor.setHighlighter(_format.getHighlighter()); _hlEditor.setDynamicHighlightingEnabled(_appSettings.isDynamicHighlightingEnabled()); _hlEditor.setAutoFormatters(_format.getAutoFormatInputFilter(), _format.getAutoFormatTextWatcher()); - _hlEditor.setAutoFormatEnabled(_appSettings.getDocumentAutoFormatEnabled(_document.getPath())); + _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()); @@ -801,7 +799,7 @@ public void errorClipText() { } public boolean isSdStatusBad() { - if (_cu.isUnderStorageAccessFolder(getContext(), _document.getFile(), false) && + if (_cu.isUnderStorageAccessFolder(getContext(), _document.file, false) && _cu.getStorageAccessFrameworkTreeUri(getContext()) == null) { _cu.showMountSdDialog(getActivity()); return true; @@ -814,7 +812,7 @@ public boolean isStateBad() { return (_document == null || _hlEditor == null || _appSettings == null || - !_cu.canWriteFile(getContext(), _document.getFile(), false, true)); + !_cu.canWriteFile(getContext(), _document.file, false, true)); } // Save the file diff --git a/app/src/main/java/net/gsantner/markor/activity/DocumentShareIntoFragment.java b/app/src/main/java/net/gsantner/markor/activity/DocumentShareIntoFragment.java index 0c0676602..4a688fbb7 100644 --- a/app/src/main/java/net/gsantner/markor/activity/DocumentShareIntoFragment.java +++ b/app/src/main/java/net/gsantner/markor/activity/DocumentShareIntoFragment.java @@ -224,7 +224,7 @@ protected void afterOnCreate(Bundle savedInstances, Context context) { if (_editor != null && _linkCheckBox != null) { doUpdatePreferences(); _linkCheckBox.setVisibility(hasLinks(_editor.getText()) ? View.VISIBLE : View.GONE); - _linkCheckBox.setChecked(true); + _linkCheckBox.setChecked(_appSettings.getFormatShareAsLink()); _editor.addTextChangedListener(GsTextWatcherAdapter.on((ctext, arg2, arg3, arg4) -> _linkCheckBox.setVisibility(hasLinks(_editor.getText()) ? View.VISIBLE : View.GONE))); } @@ -241,8 +241,9 @@ private void appendToExistingDocumentAndClose(final File file, final boolean sho } final Document document = new Document(file); - final int format = _appSettings.getDocumentFormat(document.getPath(), document.getFormat()); - final String formatted = getFormatted(shareAsLink(), file, format); + final int format = _appSettings.getDocumentFormat(document.path, document.getFormat()); + final boolean asLink = shareAsLink(); + final String formatted = getFormatted(asLink, file, format); final String oldContent = document.loadContent(activity); if (oldContent != null) { @@ -254,9 +255,10 @@ private void appendToExistingDocumentAndClose(final File file, final boolean sho } _appSettings.addRecentFile(file); + _appSettings.setFormatShareAsLink(asLink); if (showEditor) { - DocumentActivity.launch(activity, document.getFile(), null, -1); + DocumentActivity.launch(activity, document.file, null, -1); } activity.finish(); diff --git a/app/src/main/java/net/gsantner/markor/activity/MainActivity.java b/app/src/main/java/net/gsantner/markor/activity/MainActivity.java index 4bfc88b81..929a69eee 100644 --- a/app/src/main/java/net/gsantner/markor/activity/MainActivity.java +++ b/app/src/main/java/net/gsantner/markor/activity/MainActivity.java @@ -14,7 +14,6 @@ import android.graphics.Color; import android.os.Build; import android.os.Bundle; -import android.os.Handler; import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; @@ -31,7 +30,6 @@ import com.google.android.material.bottomnavigation.BottomNavigationView; import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.material.navigation.NavigationBarView; import net.gsantner.markor.BuildConfig; import net.gsantner.markor.R; @@ -52,7 +50,7 @@ import other.writeily.widget.WrMarkorWidgetProvider; -public class MainActivity extends MarkorBaseActivity implements GsFileBrowserFragment.FilesystemFragmentOptionsListener, NavigationBarView.OnItemSelectedListener { +public class MainActivity extends MarkorBaseActivity implements GsFileBrowserFragment.FilesystemFragmentOptionsListener { public static boolean IS_DEBUG_ENABLED = false; @@ -63,7 +61,6 @@ public class MainActivity extends MarkorBaseActivity implements GsFileBrowserFra private MoreFragment _more; private FloatingActionButton _fab; - private boolean _doubleBackToExitPressedOnce; private MarkorContextUtils _cu; private File _quickSwitchPrevFolder = null; @@ -101,7 +98,11 @@ public void onPageSelected(int position) { // Setup viewpager _viewPager.setAdapter(new SectionsPagerAdapter(getSupportFragmentManager())); _viewPager.setOffscreenPageLimit(4); - _bottomNav.setOnItemSelectedListener(this); + _bottomNav.setOnItemSelectedListener((item) -> { + _viewPager.setCurrentItem(tabIdToPos(item.getItemId())); + return true; + }); + reduceViewpagerSwipeSensitivity(); // noinspection PointlessBooleanExpression - Send Test intent @@ -324,38 +325,20 @@ private void newItemCallback(final File file) { @Override public void onBackPressed() { - // Exit confirmed with 2xBack - if (_doubleBackToExitPressedOnce) { - super.onBackPressed(); - _appSettings.setFileBrowserLastBrowsedFolder(_appSettings.getNotebookDirectory()); - return; - } - // Check if fragment handled back press final GsFragmentBase frag = getPosFragment(getCurrentPos()); - if (frag != null && frag.onBackPressed()) { - return; + if (frag == null || !frag.onBackPressed()) { + super.onBackPressed(); } - - // Confirm exit with back / snack bar - _doubleBackToExitPressedOnce = true; - _cu.showSnackBar(this, R.string.press_back_again_to_exit, false, R.string.exit, view -> finish()); - new Handler().postDelayed(() -> _doubleBackToExitPressedOnce = false, 2000); - } - - @Override - public boolean onNavigationItemSelected(@NonNull MenuItem item) { - _viewPager.setCurrentItem(tabIdToPos(item.getItemId())); - return true; } public String getFileBrowserTitle() { - final File file = _appSettings.getFileBrowserLastBrowsedFolder(); - String title = getString(R.string.app_name); - if (!_appSettings.getNotebookDirectory().getAbsolutePath().equals(file.getAbsolutePath())) { - title = "> " + file.getName(); + final File file = _notebook.getCurrentFolder(); + if (file != null && !_appSettings.getNotebookDirectory().equals(file)) { + return "> " + file.getName(); + } else { + return getString(R.string.app_name); } - return title; } public int tabIdToPos(final int id) { @@ -406,7 +389,7 @@ public void onViewPagerPageSelected(final int pos) { if (pos == tabIdToPos(R.id.nav_notebook)) { _fab.show(); - _cu.showSoftKeyboard(this, false); + _cu.showSoftKeyboard(this, false, _notebook.getView()); } else { _fab.hide(); restoreDefaultToolbar(); diff --git a/app/src/main/java/net/gsantner/markor/activity/openeditor/OpenFromShortcutOrWidgetActivity.java b/app/src/main/java/net/gsantner/markor/activity/openeditor/OpenFromShortcutOrWidgetActivity.java index dcd07bc9a..59cf13349 100644 --- a/app/src/main/java/net/gsantner/markor/activity/openeditor/OpenFromShortcutOrWidgetActivity.java +++ b/app/src/main/java/net/gsantner/markor/activity/openeditor/OpenFromShortcutOrWidgetActivity.java @@ -5,9 +5,6 @@ import net.gsantner.markor.activity.DocumentActivity; import net.gsantner.markor.activity.MarkorBaseActivity; -import net.gsantner.markor.util.MarkorContextUtils; - -import java.io.File; /** * This Activity exists solely to launch DocumentActivity with the correct intent @@ -28,10 +25,7 @@ protected void onNewIntent(final Intent intent) { } private void launchActivityAndFinish(Intent intent) { - final File file = MarkorContextUtils.getIntentFile(intent, null); - if (file != null) { - DocumentActivity.launch(this, file, null, null); - } + DocumentActivity.launch(this, intent); finish(); } } \ No newline at end of file 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 dad792598..cdf6b4156 100644 --- a/app/src/main/java/net/gsantner/markor/format/ActionButtonBase.java +++ b/app/src/main/java/net/gsantner/markor/format/ActionButtonBase.java @@ -15,6 +15,8 @@ import android.content.SharedPreferences; import android.os.Handler; import android.text.Editable; +import android.text.Selection; +import android.text.Spannable; import android.text.TextUtils; import android.view.HapticFeedbackConstants; import android.view.KeyEvent; @@ -47,7 +49,6 @@ import net.gsantner.opoc.util.GsCollectionUtils; import net.gsantner.opoc.util.GsContextUtils; import net.gsantner.opoc.util.GsFileUtils; -import net.gsantner.opoc.wrapper.GsCallback; import java.io.File; import java.util.ArrayList; @@ -88,7 +89,7 @@ public ActionButtonBase(@NonNull final Context context, final Document document) _document = document; _appSettings = ApplicationObject.settings(); _buttonHorizontalMargin = GsContextUtils.instance.convertDpToPx(context, _appSettings.getEditorActionButtonItemPadding()); - _indent = _appSettings.getDocumentIndentSize(_document != null ? _document.getPath() : null); + _indent = _appSettings.getDocumentIndentSize(_document != null ? _document.path : null); } // Override to implement custom onClick @@ -473,36 +474,48 @@ public static void runRegexReplaceAction(final Editable editable, final ReplaceP private static void runRegexReplaceAction(final Editable editable, final List patterns) { - TextViewUtils.withKeepSelection(editable, (selStart, selEnd) -> { + final int[] sel = TextViewUtils.getSelection(editable); + if (sel[0] < 0) { + return; + } + final int[][] offsets = TextViewUtils.getLineOffsetFromIndex(editable, sel); - final TextViewUtils.ChunkedEditable text = TextViewUtils.ChunkedEditable.wrap(editable); - // Start of line on which sel begins - final int selStartStart = TextViewUtils.getLineStart(text, selStart); + final TextViewUtils.ChunkedEditable text = TextViewUtils.ChunkedEditable.wrap(editable); + // Start of line on which sel begins + final int selStartStart = TextViewUtils.getLineStart(text, sel[0]); - // Number of lines we will be modifying - final int lineCount = GsTextUtils.countChars(text, selStart, selEnd, '\n')[0] + 1; - int lineStart = selStartStart; + // Number of lines we will be modifying + final int lineCount = GsTextUtils.countChars(text, sel[0], sel[1], '\n')[0] + 1; + int lineStart = selStartStart; - for (int i = 0; i < lineCount; i++) { + for (int i = 0; i < lineCount; i++) { - int lineEnd = TextViewUtils.getLineEnd(text, lineStart); - final String line = TextViewUtils.toString(text, lineStart, lineEnd); + int lineEnd = TextViewUtils.getLineEnd(text, lineStart); + final String line = TextViewUtils.toString(text, lineStart, lineEnd); - for (final ReplacePattern pattern : patterns) { - if (pattern.matcher.reset(line).find()) { - if (!pattern.isSameReplace()) { - text.replace(lineStart, lineEnd, pattern.replace()); - } - break; + for (final ReplacePattern pattern : patterns) { + if (pattern.matcher.reset(line).find()) { + if (!pattern.isSameReplace()) { + text.replace(lineStart, lineEnd, pattern.replace()); } + break; } - - lineStart = TextViewUtils.getLineEnd(text, lineStart) + 1; } - text.applyChanges(); - }); + lineStart = TextViewUtils.getLineEnd(text, lineStart) + 1; + } + + text.applyChanges(); + TextViewUtils.setSelectionFromOffsets(editable, offsets); + } + + public static void surroundBlock(final Editable text, final String delim) { + final int[] sel = TextViewUtils.getLineSelection(text); + if (text != null && sel[0] >= 0) { + final CharSequence line = text.subSequence(sel[0], sel[1]); + text.replace(sel[0], sel[1], delim + "\n" + line + "\n" + delim); + } } protected void runSurroundAction(final String delim) { @@ -519,14 +532,14 @@ protected void runSurroundAction(final String delim) { */ protected void runSurroundAction(final String open, final String close, final boolean trim) { final Editable text = _hlEditor.getText(); - if (text == null) { + final int[] sel = TextViewUtils.getSelection(text); + if (sel[0] < 0) { return; } // Detect if delims within or around selection // If so, remove it // ------------------------------------------------------------------------- - final int[] sel = TextViewUtils.getSelection(_hlEditor); final int ss = sel[0], se = sel[1]; final int ol = open.length(), cl = close.length(), sl = se - ss; // Left as a CharSequence to help maintain spans @@ -640,15 +653,15 @@ protected final boolean runCommonAction(final @StringRes int action) { return true; } case R.string.abid_common_insert_audio: { - AttachLinkOrFileDialog.showInsertImageOrLinkDialog(AttachLinkOrFileDialog.AUDIO_ACTION, _document.getFormat(), _activity, text, _document.getFile()); + AttachLinkOrFileDialog.showInsertImageOrLinkDialog(AttachLinkOrFileDialog.AUDIO_ACTION, _document.getFormat(), _activity, text, _document.file); return true; } case R.string.abid_common_insert_link: { - AttachLinkOrFileDialog.showInsertImageOrLinkDialog(AttachLinkOrFileDialog.FILE_OR_LINK_ACTION, _document.getFormat(), _activity, text, _document.getFile()); + AttachLinkOrFileDialog.showInsertImageOrLinkDialog(AttachLinkOrFileDialog.FILE_OR_LINK_ACTION, _document.getFormat(), _activity, text, _document.file); return true; } case R.string.abid_common_insert_image: { - AttachLinkOrFileDialog.showInsertImageOrLinkDialog(AttachLinkOrFileDialog.IMAGE_ACTION, _document.getFormat(), _activity, text, _document.getFile()); + AttachLinkOrFileDialog.showInsertImageOrLinkDialog(AttachLinkOrFileDialog.IMAGE_ACTION, _document.getFormat(), _activity, text, _document.file); return true; } case R.string.abid_common_ordered_list_renumber: { @@ -669,13 +682,17 @@ 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.getTitle(), TextViewUtils.getSelectedText(_hlEditor))); + _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(snip, _document.title, TextViewUtils.getSelectedText(_hlEditor))); _lastSnip = snip; }); return true; } case R.string.abid_common_open_link_browser: { final int sel = TextViewUtils.getSelection(_hlEditor)[0]; + if (sel < 0) { + return true; + } + final String line = TextViewUtils.getSelectedLines(_hlEditor, sel); final int cursor = sel - TextViewUtils.getLineStart(_hlEditor.getText(), sel); @@ -686,13 +703,12 @@ protected final boolean runCommonAction(final @StringRes int action) { if (WEB_URL.matcher(resource).matches()) { url = resource; } else { - final File f = GsFileUtils.makeAbsolute(resource, _document.getFile().getParentFile()); + final File f = GsFileUtils.makeAbsolute(resource, _document.file.getParentFile()); if (f.canRead()) { DocumentActivity.launch(getActivity(), f, null, null); return true; } } - } // Then try to pull a tag @@ -703,6 +719,7 @@ protected final boolean runCommonAction(final @StringRes int action) { } _cu.openWebpageInExternalBrowser(getContext(), url); } + return true; } case R.string.abid_common_special_key: { @@ -711,15 +728,20 @@ 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 - _hlEditor.setSelection(TextViewUtils.getLineEnd(text, TextViewUtils.getSelection(_hlEditor)[1])); - _hlEditor.simulateKeyPress(KeyEvent.KEYCODE_ENTER); + final int sel = TextViewUtils.getSelection(_hlEditor)[1]; + if (sel > 0) { + _hlEditor.setSelection(TextViewUtils.getLineEnd(text, sel)); + _hlEditor.simulateKeyPress(KeyEvent.KEYCODE_ENTER); + } return true; } case R.string.abid_common_delete_lines: { - final int[] sel = TextViewUtils.getLineSelection(_hlEditor); - final boolean lastLine = sel[1] == text.length(); - final boolean firstLine = sel[0] == 0; - text.delete(sel[0] - (lastLine && !firstLine ? 1 : 0), sel[1] + (lastLine ? 0 : 1)); + final int[] sel = TextViewUtils.getLineSelection(text); + if (GsTextUtils.isValidSelection(text, sel)) { + final boolean lastLine = sel[1] == text.length(); + final boolean firstLine = sel[0] == 0; + text.delete(sel[0] - (lastLine && !firstLine ? 1 : 0), sel[1] + (lastLine ? 0 : 1)); + } return true; } case R.string.abid_common_duplicate_lines: { @@ -740,7 +762,7 @@ protected final boolean runCommonAction(final @StringRes int action) { return true; } case R.string.abid_common_view_file_in_other_app: { - _cu.viewFileInOtherApp(getContext(), _document.getFile(), GsFileUtils.getMimeType(_document.getFile())); + _cu.viewFileInOtherApp(getContext(), _document.file, GsFileUtils.getMimeType(_document.file)); return true; } case R.string.abid_common_rotate_screen: { @@ -760,7 +782,7 @@ protected final boolean runCommonLongPressAction(@StringRes int action) { case R.string.abid_common_indent: { MarkorDialogFactory.showIndentSizeDialog(_activity, _indent, (size) -> { _indent = Integer.parseInt(size); - _appSettings.setDocumentIndentSize(_document.getPath(), _indent); + _appSettings.setDocumentIndentSize(_document.path, _indent); }); return true; } @@ -789,20 +811,20 @@ protected final boolean runCommonLongPressAction(@StringRes int action) { } case R.string.abid_common_insert_snippet: { if (!TextUtils.isEmpty(_lastSnip)) { - _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(_lastSnip, _document.getTitle(), TextViewUtils.getSelectedText(_hlEditor))); + _hlEditor.insertOrReplaceTextOnCursor(TextViewUtils.interpolateSnippet(_lastSnip, _document.title, TextViewUtils.getSelectedText(_hlEditor))); } return true; } case R.string.abid_common_insert_audio: { - AttachLinkOrFileDialog.insertAudioRecording(_activity, _document.getFormat(), _hlEditor.getText(), _document.getFile()); + AttachLinkOrFileDialog.insertAudioRecording(_activity, _document.getFormat(), _hlEditor.getText(), _document.file); return true; } case R.string.abid_common_insert_link: { - AttachLinkOrFileDialog.insertGalleryPhoto(_activity, _document.getFormat(), _hlEditor.getText(), _document.getFile()); + AttachLinkOrFileDialog.insertGalleryPhoto(_activity, _document.getFormat(), _hlEditor.getText(), _document.file); return true; } case R.string.abid_common_insert_image: { - AttachLinkOrFileDialog.insertCameraPhoto(_activity, _document.getFormat(), _hlEditor.getText(), _document.getFile()); + AttachLinkOrFileDialog.insertCameraPhoto(_activity, _document.getFormat(), _hlEditor.getText(), _document.file); return true; } case R.string.abid_common_new_line_below: { @@ -810,9 +832,11 @@ protected final boolean runCommonLongPressAction(@StringRes int action) { final Editable text = _hlEditor.getText(); if (text != null) { final int sel = TextViewUtils.getSelection(text)[0]; - final int lineStart = TextViewUtils.getLineStart(text, sel); - text.insert(lineStart, "\n"); - _hlEditor.setSelection(lineStart); + if (sel >= 0) { + final int lineStart = TextViewUtils.getLineStart(text, sel); + text.insert(lineStart, "\n"); + _hlEditor.setSelection(lineStart); + } } return true; } @@ -852,32 +876,30 @@ public ActionItem setRepeatable(boolean repeatable) { public static void moveLineSelectionBy1(final HighlightingEditor hlEditor, final boolean isUp) { final Editable text = hlEditor.getText(); + final int[] sel = TextViewUtils.getSelection(text); + if (text == null || sel[0] < 0) { + return; + } - final int[] sel = TextViewUtils.getSelection(hlEditor); - final int linesStart = TextViewUtils.getLineStart(text, sel[0]); - final int linesEnd = TextViewUtils.getLineEnd(text, sel[1]); + final int[] lineSel = TextViewUtils.getLineSelection(text, sel); - if ((isUp && linesStart > 0) || (!isUp && linesEnd < text.length())) { - final CharSequence lines = text.subSequence(linesStart, linesEnd); + if ((isUp && lineSel[0] > 0) || (!isUp && lineSel[1] < text.length())) { + final CharSequence lines = text.subSequence(lineSel[0], lineSel[1]); - final int altStart = isUp ? TextViewUtils.getLineStart(text, linesStart - 1) : linesEnd + 1; - final int altEnd = TextViewUtils.getLineEnd(text, altStart); - final CharSequence altLine = text.subSequence(altStart, altEnd); + final int[] altSel = TextViewUtils.getLineSelection(text, isUp ? lineSel[0] - 1 : lineSel[1] + 1); + final CharSequence altLine = text.subSequence(altSel[0], altSel[1]); - final int[] selStart = TextViewUtils.getLineOffsetFromIndex(text, sel[0]); - final int[] selEnd = TextViewUtils.getLineOffsetFromIndex(text, sel[1]); + final int[][] offsets = TextViewUtils.getLineOffsetFromIndex(text, sel); hlEditor.withAutoFormatDisabled(() -> { final String newPair = String.format("%s\n%s", isUp ? lines : altLine, isUp ? altLine : lines); - text.replace(Math.min(linesStart, altStart), Math.max(altEnd, linesEnd), newPair); + text.replace(Math.min(lineSel[0], altSel[0]), Math.max(lineSel[1], altSel[1]), newPair); }); - selStart[0] += isUp ? -1 : 1; - selEnd[0] += isUp ? -1 : 1; + offsets[0][0] += isUp ? -1 : 1; + offsets[1][0] += isUp ? -1 : 1; - hlEditor.setSelection( - TextViewUtils.getIndexFromLineOffset(text, selStart), - TextViewUtils.getIndexFromLineOffset(text, selEnd)); + TextViewUtils.setSelectionFromOffsets(text, offsets); } } @@ -886,38 +908,28 @@ public static void duplicateLineSelection(final HighlightingEditor hlEditor) { // cursor is preserved regarding column position (helpful for editing the // newly created line at the selected position right away). final Editable text = hlEditor.getText(); + final int[] sel = TextViewUtils.getSelection(text); + if (sel[0] >= 0) { + final int linesStart = TextViewUtils.getLineStart(text, sel[0]); + final int linesEnd = TextViewUtils.getLineEnd(text, sel[1]); - final int[] sel = TextViewUtils.getSelection(hlEditor); - final int linesStart = TextViewUtils.getLineStart(text, sel[0]); - final int linesEnd = TextViewUtils.getLineEnd(text, sel[1]); - - final CharSequence lines = text.subSequence(linesStart, linesEnd); - - final int[] selStart = TextViewUtils.getLineOffsetFromIndex(text, sel[0]); - final int[] selEnd = TextViewUtils.getLineOffsetFromIndex(text, sel[1]); - - hlEditor.withAutoFormatDisabled(() -> { - // Prepending the newline instead of appending it is required for making - // this logic work even if it's about the last line in the given file. - final String lines_final = String.format("\n%s", lines); - text.insert(linesEnd, lines_final); - }); + final CharSequence lines = text.subSequence(linesStart, linesEnd); - final int sel_offset = selEnd[0] - selStart[0] + 1; - selStart[0] += sel_offset; - selEnd[0] += sel_offset; + final int[][] offsets = TextViewUtils.getLineOffsetFromIndex(text, sel); - hlEditor.setSelection( - TextViewUtils.getIndexFromLineOffset(text, selStart), - TextViewUtils.getIndexFromLineOffset(text, selEnd)); - } + hlEditor.withAutoFormatDisabled(() -> { + // Prepending the newline instead of appending it is required for making + // this logic work even if it's about the last line in the given file. + final String lines_final = String.format("\n%s", lines); + text.insert(linesEnd, lines_final); + }); - public void withKeepSelection(final GsCallback.a2 action) { - _hlEditor.withAutoFormatDisabled(() -> TextViewUtils.withKeepSelection(_hlEditor.getText(), action)); - } + final int lineCount = offsets[1][0] - offsets[0][0] + 1; + offsets[0][0] += lineCount; + offsets[1][0] += lineCount; - public void withKeepSelection(final GsCallback.a0 action) { - withKeepSelection((start, end) -> action.callback()); + TextViewUtils.setSelectionFromOffsets(text, offsets); + } } // Derived classes should override this to implement format-specific renumber logic @@ -940,12 +952,6 @@ private String rstr(@StringRes int resKey) { } public void runSpecialKeyAction() { - // Needed to prevent selection from being overwritten on refocus - final int[] sel = TextViewUtils.getSelection(_hlEditor); - _hlEditor.clearFocus(); - _hlEditor.requestFocus(); - _hlEditor.setSelection(sel[0], sel[1]); - MarkorDialogFactory.showSpecialKeyDialog(getActivity(), _specialKeyDialogState, (callbackPayload) -> { if (!_hlEditor.hasSelection() && _hlEditor.length() > 0) { _hlEditor.requestFocus(); @@ -985,11 +991,18 @@ public void runSpecialKeyAction() { } else if (callbackPayload.equals(rstr(R.string.char_punctation_mark_arrows))) { _hlEditor.insertOrReplaceTextOnCursor("»«"); } else if (callbackPayload.equals(rstr(R.string.select_current_line))) { - _hlEditor.setSelectionExpandWholeLines(); + selectWholeLines(_hlEditor.getText()); } }); } + public static void selectWholeLines(final @Nullable Spannable text) { + final int[] sel = TextViewUtils.getLineSelection(text); + if (sel[0] >= 0) { + Selection.setSelection(text, sel[0], sel[1]); + } + } + public void runJumpBottomTopAction(ActionItem.DisplayMode displayMode) { if (displayMode == ActionItem.DisplayMode.EDIT) { final int pos = _hlEditor.getSelectionStart(); diff --git a/app/src/main/java/net/gsantner/markor/format/TextConverterBase.java b/app/src/main/java/net/gsantner/markor/format/TextConverterBase.java index a3dbb2a75..e68a10cd2 100644 --- a/app/src/main/java/net/gsantner/markor/format/TextConverterBase.java +++ b/app/src/main/java/net/gsantner/markor/format/TextConverterBase.java @@ -108,12 +108,12 @@ public String convertMarkupShowInWebView( final boolean lineNum) { String html; try { - html = convertMarkup(content, context, lightMode, lineNum, document.getFile()); + html = convertMarkup(content, context, lightMode, lineNum, document.file); } catch (Exception e) { html = "Please report at project issue tracker: " + e; } - String parent = document.getFile().getParent(); + String parent = document.file.getParent(); if (parent == null) { parent = _appSettings.getNotebookDirectory().getAbsolutePath(); } diff --git a/app/src/main/java/net/gsantner/markor/format/asciidoc/AsciidocActionButtons.java b/app/src/main/java/net/gsantner/markor/format/asciidoc/AsciidocActionButtons.java index 3d96054bd..711edc3ed 100644 --- a/app/src/main/java/net/gsantner/markor/format/asciidoc/AsciidocActionButtons.java +++ b/app/src/main/java/net/gsantner/markor/format/asciidoc/AsciidocActionButtons.java @@ -242,11 +242,6 @@ private String rstr(@StringRes int resKey) { // idea based on runSpecialKeyAction() private void runAsciidocSpecialKeyAction() { - // Needed to prevent selection from being overwritten on refocus - final int[] sel = TextViewUtils.getSelection(_hlEditor); - _hlEditor.clearFocus(); - _hlEditor.requestFocus(); - _hlEditor.setSelection(sel[0], sel[1]); // showAsciidocSpecialKeyDialog is used instead of showSpecialKeyDialog MarkorDialogFactory.showAsciidocSpecialKeyDialog(getActivity(), (callbackPayload) -> { if (!_hlEditor.hasSelection() && _hlEditor.length() > 0) { 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 65c257b1b..e5e4e319e 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 @@ -160,15 +160,18 @@ public boolean onActionClick(final @StringRes int action) { * Used to surround selected text with a given delimiter (and remove it if present) *

* Not super intelligent about how patterns can be combined. - * Current regexes just look for the litera delimiters. + * Current regexes just look for the literal delimiters. * * @param pattern - Pattern to match if delimiter is present * @param delim - Delimiter to surround text with */ private void runLineSurroundAction(final Pattern pattern, final String delim) { final int[] sel = TextViewUtils.getSelection(_hlEditor); - final String lineBefore = sel[0] == sel[1] ? TextViewUtils.getSelectedLines(_hlEditor, sel[0]) : null; + if (sel[0] < 0) { + return; + } + final String lineBefore = sel[0] == sel[1] ? TextViewUtils.getSelectedLines(_hlEditor, sel[0]) : null; runRegexReplaceAction( new ReplacePattern(pattern, "$1$2$4$6"), new ReplacePattern(LINE_NONE, "$1$2" + delim + "$3" + delim + "$4") @@ -196,12 +199,7 @@ public boolean onActionLongClick(final @StringRes int action) { return true; } case R.string.abid_markdown_code_inline: { - _hlEditor.withAutoFormatDisabled(() -> { - final int c = _hlEditor.setSelectionExpandWholeLines(); - _hlEditor.getText().insert(_hlEditor.getSelectionStart(), "\n```\n"); - _hlEditor.getText().insert(_hlEditor.getSelectionEnd(), "\n```\n"); - _hlEditor.setSelection(c + "\n```\n".length()); - }); + _hlEditor.withAutoFormatDisabled(() -> surroundBlock(_hlEditor.getText(), "```")); return true; } case R.string.abid_markdown_bold: { @@ -277,7 +275,7 @@ private boolean followLinkUnderCursor() { GsContextUtils.instance.openWebpageInExternalBrowser(getActivity(), link.link); return true; } else { - final File f = GsFileUtils.makeAbsolute(link.link, _document.getFile().getParentFile()); + final File f = GsFileUtils.makeAbsolute(link.link, _document.file.getParentFile()); if (GsFileUtils.canCreate(f)) { DocumentActivity.launch(getActivity(), f, null, null); return true; 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 6b71386f6..dffe3adbf 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 @@ -124,49 +124,7 @@ public boolean onActionClick(final @StringRes int action) { return true; } case R.string.abid_todotxt_archive_done_tasks: { - final String last = _appSettings.getLastTodoDoneName(_document.getPath()); - MarkorDialogFactory.showSttArchiveDialog(getActivity(), last, (callbackPayload) -> { - callbackPayload = Document.normalizeFilename(callbackPayload); - - final ArrayList keep = new ArrayList<>(); - final ArrayList move = new ArrayList<>(); - final List allTasks = TodoTxtTask.getAllTasks(_hlEditor.getText()); - - final int[] sel = TextViewUtils.getSelection(_hlEditor); - final CharSequence text = _hlEditor.getText(); - final int[] selStart = TextViewUtils.getLineOffsetFromIndex(text, sel[0]); - final int[] selEnd = TextViewUtils.getLineOffsetFromIndex(text, sel[1]); - - for (int i = 0; i < allTasks.size(); i++) { - final TodoTxtTask task = allTasks.get(i); - if (task.isDone()) { - move.add(task); - if (i <= selStart[0]) selStart[0]--; - if (i <= selEnd[0]) selEnd[0]--; - } else { - keep.add(task); - } - } - if (!move.isEmpty() && _document.testCreateParent()) { - File doneFile = new File(_document.getFile().getParentFile(), callbackPayload); - String doneFileContents = ""; - if (doneFile.exists() && doneFile.canRead()) { - doneFileContents = GsFileUtils.readTextFileFast(doneFile).first.trim() + "\n"; - } - doneFileContents += TodoTxtTask.tasksToString(move) + "\n"; - - // Write to done file - if (new Document(doneFile).saveContent(getActivity(), doneFileContents)) { - final String tasksString = TodoTxtTask.tasksToString(keep); - _hlEditor.setText(tasksString); - _hlEditor.setSelection( - TextViewUtils.getIndexFromLineOffset(tasksString, selStart), - TextViewUtils.getIndexFromLineOffset(tasksString, selEnd) - ); - } - } - _appSettings.setLastTodoDoneName(_document.getPath(), callbackPayload); - }); + archiveDoneTasks(); return true; } case R.string.abid_todotxt_sort_todo: { @@ -218,6 +176,48 @@ public boolean onActionLongClick(final @StringRes int action) { } } + public void archiveDoneTasks() { + final String lastDoneName = _appSettings.getLastTodoDoneName(_document.path); + MarkorDialogFactory.showSttArchiveDialog(getActivity(), lastDoneName, callbackPayload -> { + final String doneName = Document.normalizeFilename(callbackPayload); + final CharSequence text = _hlEditor.getText(); + final int[] sel = TextViewUtils.getSelection(text); + final int[][] offsets = TextViewUtils.getLineOffsetFromIndex(text, sel); + + final ArrayList keep = new ArrayList<>(); + final ArrayList move = new ArrayList<>(); + final List allTasks = TodoTxtTask.getAllTasks(text); + + for (int i = 0; i < allTasks.size(); i++) { + final TodoTxtTask task = allTasks.get(i); + if (task.isDone()) { + move.add(task); + if (i <= offsets[0][0]) offsets[0][0]--; + if (i <= offsets[1][0]) offsets[1][0]--; + } else { + keep.add(task); + } + } + + if (!move.isEmpty() && _document.testCreateParent()) { + final File doneFile = new File(_document.file.getParentFile(), doneName); + final StringBuilder doneContents = new StringBuilder(); + if (doneFile.exists() && doneFile.canRead()) { + doneContents.append(GsFileUtils.readTextFileFast(doneFile).first.trim()).append("\n"); + } + doneContents.append(TodoTxtTask.tasksToString(move)).append("\n"); + + // Write to done file + if (new Document(doneFile).saveContent(getActivity(), doneContents.toString())) { + final String tasksString = TodoTxtTask.tasksToString(keep); + _hlEditor.setText(tasksString); + TextViewUtils.setSelectionFromOffsets(_hlEditor, offsets); + } + } + _appSettings.setLastTodoDoneName(_document.path, doneName); + }); + } + @Override public boolean runTitleClick() { MarkorDialogFactory.showSttFilteringDialog(getActivity(), _hlEditor); @@ -288,6 +288,10 @@ private void trimLeadingWhiteSpace() { private static void insertInline(final Editable editable, String thing) { final int[] sel = TextViewUtils.getSelection(editable); + if (sel[0] < 0) { + return; + } + if (sel[0] > 0) { final char before = editable.charAt(sel[0] - 1); if (before != ' ' && before != '\n') { @@ -318,12 +322,15 @@ private static Calendar parseDateString(final String dateString, final Calendar } private void setDate() { - final int[] sel = TextViewUtils.getSelection(_hlEditor); final Editable text = _hlEditor.getText(); + final int[] sel = TextViewUtils.getSelection(text); + if (text == null || sel[0] < 0) { + return; + } final String selStr = text.subSequence(sel[0], sel[1]).toString(); - Calendar initDate = parseDateString(selStr, Calendar.getInstance()); + final Calendar initDate = parseDateString(selStr, Calendar.getInstance()); - DatePickerDialog.OnDateSetListener listener = (_view, year, month, day) -> { + final DatePickerDialog.OnDateSetListener listener = (_view, year, month, day) -> { Calendar fmtCal = Calendar.getInstance(); fmtCal.set(year, month, day); final String newDate = TodoTxtTask.DATEF_YYYY_MM_DD.format(fmtCal.getTime()); 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 8301e9b82..37bfd4fb1 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 @@ -11,7 +11,6 @@ import android.graphics.Paint; import android.text.style.LineBackgroundSpan; import android.text.style.LineHeightSpan; -import android.text.style.UpdateLayout; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; @@ -45,7 +44,7 @@ public void generateSpans() { } // Adds spacing and divider line between paragraphs - public static class ParagraphDividerSpan implements LineBackgroundSpan, LineHeightSpan, UpdateLayout { + public static class ParagraphDividerSpan implements LineBackgroundSpan, LineHeightSpan, StaticSpan { private final int _lineColor; private Integer _origAscent = null; 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 3d7d007bf..cd6d0f42b 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 @@ -64,26 +64,26 @@ public static String getToday() { return DATEF_YYYY_MM_DD.format(new Date()); } - public static List getTasks(final CharSequence text, final int selStart, final int selEnd) { - final String[] lines = text.subSequence( - TextViewUtils.getLineStart(text, selStart), - TextViewUtils.getLineEnd(text, selEnd) - ).toString().split("\n"); - + public static List getTasks(final CharSequence text, final int[] sel) { final List tasks = new ArrayList<>(); - for (final String line : lines) { - tasks.add(new TodoTxtTask(line)); + if (GsTextUtils.isValidSelection(text, sel)) { + + final int[] lsel = TextViewUtils.getLineSelection(text, sel); + final String[] lines = text.subSequence(lsel[0], lsel[1]).toString().split("\n"); + + for (final String line : lines) { + tasks.add(new TodoTxtTask(line)); + } } return tasks; } public static List getSelectedTasks(final TextView view) { - final int[] sel = TextViewUtils.getSelection(view); - return getTasks(view.getText(), sel[0], sel[1]); + return getTasks(view.getText(), TextViewUtils.getSelection(view)); } public static List getAllTasks(final CharSequence text) { - return getTasks(text, 0, text.length()); + return getTasks(text, new int[] {0, text.length()}); } public static List getProjects(final List tasks) { 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 f5a6bff11..c54ce8ab2 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 @@ -164,12 +164,7 @@ public boolean onActionLongClick(final @StringRes int action) { return true; } case R.string.abid_wikitext_code_inline: { - _hlEditor.withAutoFormatDisabled(() -> { - final int c = _hlEditor.setSelectionExpandWholeLines(); - _hlEditor.getText().insert(_hlEditor.getSelectionStart(), "\n'''\n"); - _hlEditor.getText().insert(_hlEditor.getSelectionEnd(), "\n'''\n"); - _hlEditor.setSelection(c + "\n'''\n".length()); - }); + _hlEditor.withAutoFormatDisabled(() -> surroundBlock(_hlEditor.getText(), "'''")); return true; } default: { @@ -188,7 +183,7 @@ private void openLink() { return; } - WikitextLinkResolver resolver = WikitextLinkResolver.resolve(fullWikitextLink, _appSettings.getNotebookDirectory(), _document.getFile(), _appSettings.isWikitextDynamicNotebookRootEnabled()); + WikitextLinkResolver resolver = WikitextLinkResolver.resolve(fullWikitextLink, _appSettings.getNotebookDirectory(), _document.file, _appSettings.isWikitextDynamicNotebookRootEnabled()); String resolvedLink = resolver.getResolvedLink(); if (resolvedLink == null) { return; diff --git a/app/src/main/java/net/gsantner/markor/frontend/AttachLinkOrFileDialog.java b/app/src/main/java/net/gsantner/markor/frontend/AttachLinkOrFileDialog.java index ff7a0cf2c..75f2403eb 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/AttachLinkOrFileDialog.java +++ b/app/src/main/java/net/gsantner/markor/frontend/AttachLinkOrFileDialog.java @@ -27,7 +27,6 @@ import net.gsantner.markor.R; import net.gsantner.markor.format.FormatRegistry; import net.gsantner.markor.format.markdown.MarkdownActionButtons; -import net.gsantner.markor.format.markdown.MarkdownSyntaxHighlighter; import net.gsantner.markor.format.wikitext.WikitextLinkResolver; import net.gsantner.markor.frontend.filebrowser.MarkorFileBrowserFactory; import net.gsantner.markor.frontend.filesearch.FileSearchDialog; @@ -63,7 +62,7 @@ private static String getLinkFormat(final int textFormatId) { if (textFormatId == FormatRegistry.FORMAT_MARKDOWN) { return "[%TITLE%](%LINK%)"; } else if (textFormatId == FormatRegistry.FORMAT_WIKITEXT) { - return "[[LINK|TITLE]]"; + return "[[%LINK%|%TITLE%]]"; } else if (textFormatId == FormatRegistry.FORMAT_ASCIIDOC) { return "link:%LINK%[%TITLE%]"; } else if (textFormatId == FormatRegistry.FORMAT_TODOTXT) { @@ -75,9 +74,10 @@ private static String getLinkFormat(final int textFormatId) { private static String getAudioFormat(final int textFormatId) { if (textFormatId == FormatRegistry.FORMAT_WIKITEXT) { - return "[[LINK|TITLE]]"; + return "[[%LINK%|%TITLE%]]"; + } else { + return ""; } - return ""; } public static void showInsertImageOrLinkDialog( 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 24ce7be4e..8e9662e3f 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java +++ b/app/src/main/java/net/gsantner/markor/frontend/MarkorDialogFactory.java @@ -289,6 +289,7 @@ public static void showSttFilteringDialog(final Activity activity, final EditTex // Delete view doptView.neutralButtonText = R.string.delete; + doptView.isSoftInputVisible = false; doptView.neutralButtonCallback = viewDialog -> { final DialogOptions confirmDopt = new DialogOptions(); baseConf(activity, confirmDopt); @@ -1019,4 +1020,4 @@ public static void baseConf(Activity activity, DialogOptions dopt) { dopt.highlightColor = ContextCompat.getColor(activity, R.color.accent); dopt.dialogStyle = R.style.Theme_AppCompat_DayNight_Dialog_Rounded; } -} +} \ 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 3e857d540..2821db277 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/NewFileDialog.java +++ b/app/src/main/java/net/gsantner/markor/frontend/NewFileDialog.java @@ -95,19 +95,12 @@ public static NewFileDialog newInstance( public Dialog onCreateDialog(Bundle savedInstanceState) { final File file = (File) getArguments().getSerializable(EXTRA_DIR); final boolean allowCreateDir = getArguments().getBoolean(EXTRA_ALLOW_CREATE_DIR); - - LayoutInflater inflater = LayoutInflater.from(getActivity()); - AlertDialog.Builder dialogBuilder = makeDialog(file, allowCreateDir, inflater); - AlertDialog dialog = dialogBuilder.show(); - Window w; - if ((w = dialog.getWindow()) != null) { - w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT); - } - return dialog; + final LayoutInflater inflater = LayoutInflater.from(getActivity()); + return makeDialog(file, allowCreateDir, inflater); } @SuppressLint("SetTextI18n") - private AlertDialog.Builder makeDialog(final File basedir, final boolean allowCreateDir, LayoutInflater inflater) { + private AlertDialog makeDialog(final File basedir, final boolean allowCreateDir, LayoutInflater inflater) { final Activity activity = getActivity(); final AppSettings appSettings = ApplicationObject.settings(); final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(inflater.getContext(), R.style.Theme_AppCompat_DayNight_Dialog_Rounded); @@ -303,8 +296,8 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { document.saveContent(activity, content.first, cu, true); // We only make these changes if the file did not already exist - appSettings.setDocumentFormat(document.getPath(), fmt.format); - appSettings.setLastEditPosition(document.getPath(), content.second); + appSettings.setDocumentFormat(document.path, fmt.format); + appSettings.setLastEditPosition(document.path, content.second); callback(file); @@ -349,9 +342,15 @@ public void onTextChanged(CharSequence s, int start, int before, int count) { final List indices = GsCollectionUtils.indices(formats, f -> f.format == lastUsedType); typeSpinner.setSelection(indices.isEmpty() ? 0 : indices.get(0)); - titleEdit.requestFocus(); + final AlertDialog dialog = dialogBuilder.show(); + final Window win = dialog.getWindow(); + if (win != null) { + win.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.WRAP_CONTENT); + win.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN | WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + } + titleEdit.post(titleEdit::requestFocus); - return dialogBuilder; + return dialog; } private void callback(final File file) { @@ -368,11 +367,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 = template.indexOf(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN); - text = text.replace(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN, ""); + final int startingIndex = text.indexOf(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN); + text = text.replaceAll(HighlightingEditor.PLACE_CURSOR_HERE_TOKEN, ""); // Has no utility in a new file - text = text.replace(HighlightingEditor.INSERT_SELECTION_HERE_TOKEN, ""); + text = text.replaceAll(HighlightingEditor.INSERT_SELECTION_HERE_TOKEN, ""); return Pair.create(text, startingIndex); } diff --git a/app/src/main/java/net/gsantner/markor/frontend/filebrowser/MarkorFileBrowserFactory.java b/app/src/main/java/net/gsantner/markor/frontend/filebrowser/MarkorFileBrowserFactory.java index 677b280f6..d781212b9 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/filebrowser/MarkorFileBrowserFactory.java +++ b/app/src/main/java/net/gsantner/markor/frontend/filebrowser/MarkorFileBrowserFactory.java @@ -53,7 +53,6 @@ public static GsFileBrowserOptions.Options prepareFsViewerOpts( opts.newDirButtonText = R.string.create_folder; opts.upButtonEnable = true; opts.homeButtonEnable = true; - opts.mustStartWithRootFolder = false; opts.contentDescriptionFolder = R.string.folder; opts.contentDescriptionSelected = R.string.selected; opts.contentDescriptionFile = R.string.file; @@ -68,6 +67,7 @@ public static GsFileBrowserOptions.Options prepareFsViewerOpts( opts.folderColor = R.color.folder; opts.fileImage = R.drawable.ic_file_white_24dp; opts.folderImage = R.drawable.ic_folder_white_24dp; + opts.descriptionFormat = appSettings.getString(R.string.pref_key__file_description_format, ""); opts.titleText = R.string.select; @@ -109,6 +109,7 @@ public static GsFileBrowserDialog showFileDialog( ) { final GsFileBrowserOptions.Options opts = prepareFsViewerOpts(context, false, listener); opts.fileOverallFilter = fileOverallFilter; + opts.descModtimeInsteadOfParent = true; return showDialog(fm, opts); } @@ -119,6 +120,7 @@ public static GsFileBrowserDialog showFolderDialog( ) { final GsFileBrowserOptions.Options opts = prepareFsViewerOpts(context, true, listener); opts.okButtonText = R.string.select_this_folder; + opts.descModtimeInsteadOfParent = true; return showDialog(fm, opts); } } 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 7491210e0..4cc1c62ab 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 @@ -34,6 +34,12 @@ import net.gsantner.opoc.wrapper.GsCallback; import net.gsantner.opoc.wrapper.GsTextWatcherAdapter; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + @SuppressWarnings("UnusedReturnValue") public class HighlightingEditor extends AppCompatEditText { @@ -58,7 +64,8 @@ public class HighlightingEditor extends AppCompatEditText { private boolean _autoFormatEnabled; private boolean _saveInstanceState = true; private final LineNumbersDrawer _lineNumbersDrawer = new LineNumbersDrawer(this); - + private final ExecutorService executor = new ThreadPoolExecutor(0, 3, 60, TimeUnit.SECONDS, new SynchronousQueue<>()); + private final AtomicBoolean _textUnchangedWhileHighlighting = new AtomicBoolean(true); public HighlightingEditor(Context context, AttributeSet attrs) { super(context, attrs); @@ -82,6 +89,7 @@ public HighlightingEditor(Context context, AttributeSet attrs) { @Override public void onTextChanged(CharSequence s, int start, int before, int count) { if (_hlEnabled && _hl != null) { + _textUnchangedWhileHighlighting.set(false); _hl.fixup(start, before, count); } } @@ -96,8 +104,8 @@ public void afterTextChanged(final Editable s) { // Listen to and update highlighting final ViewTreeObserver observer = getViewTreeObserver(); - observer.addOnScrollChangedListener(() -> updateHighlighting(false)); - observer.addOnGlobalLayoutListener(() -> updateHighlighting(false)); + observer.addOnScrollChangedListener(this::updateHighlighting); + observer.addOnGlobalLayoutListener(this::updateHighlighting); // Fix for Android 12 perf issues - https://github.com/gsantner/markor/discussions/1794 setEmojiCompatEnabled(false); @@ -121,33 +129,73 @@ protected void onDraw(Canvas canvas) { // Highlighting // --------------------------------------------------------------------------------------------- + // Batch edit spans (or anything else, really) + // This triggers a reflow which will bring focus back to the cursor. + // Therefore it cannot be used for updating the highlighting as one scrolls + private void batch(final Runnable runnable) { + try { + beginBatchEdit(); + runnable.run(); + } finally { + endBatchEdit(); + } + } + private boolean isScrollSignificant() { - return (_oldHlRect.top - _hlRect.top) > _hlShiftThreshold || - (_hlRect.bottom - _oldHlRect.bottom) > _hlShiftThreshold; + return Math.abs(_oldHlRect.top - _hlRect.top) > _hlShiftThreshold || + Math.abs(_hlRect.bottom - _oldHlRect.bottom) > _hlShiftThreshold; + } + + // The order of tests here is important + // - we want to run getLocalVisibleRect even if recompute is true + // - we want to run isScrollSignificant after getLocalVisibleRect + // - We don't care about the presence of spans or scroll significance if recompute is true + private boolean runHighlight(final boolean recompute) { + return _hlEnabled && _hl != null && getLayout() != null && + (getLocalVisibleRect(_hlRect) || recompute) && + (recompute || _hl.hasSpans()) && + (recompute || isScrollSignificant()); } - private void updateHighlighting(final boolean recompute) { - if (_hlEnabled && _hl != null && getLayout() != null) { + private void updateHighlighting() { + if (runHighlight(false)) { + // Do not batch as we do not want to reflow + _hl.clearDynamic().applyDynamic(hlRegion()); + _oldHlRect.set(_hlRect); + } + } - final boolean visible = getLocalVisibleRect(_hlRect); + public void recomputeHighlighting() { + if (runHighlight(true)) { + batch(() -> _hl.clearAll().recompute().applyStatic().applyDynamic(hlRegion())); + } + } - // Don't highlight unless shifted sufficiently or a recompute is required - if (recompute || (visible && _hl.hasSpans() && isScrollSignificant())) { - _oldHlRect.set(_hlRect); + /** + * Computing the highlighting spans for a lot of text can be slow so we do it async + * 1. We set a flag to check that the text did not change when we were computing + * 2. We trigger the computation to a buffer + * 3. If the text did not change during computation, we apply the highlighting + */ + private void recomputeHighlightingAsync() { + if (runHighlight(true)) { + executor.execute(this::_recomputeHighlightingWorker); + } + } - final int[] newHlRegion = hlRegion(_hlRect); // Compute this _before_ clear - _hl.clearDynamic(); - if (recompute) { - _hl.clearStatic().recompute().applyStatic(); - } - _hl.applyDynamic(newHlRegion); + private synchronized void _recomputeHighlightingWorker() { + _textUnchangedWhileHighlighting.set(true); + _hl.compute(); + post(() -> { + if (_textUnchangedWhileHighlighting.get()) { + batch(() -> _hl.clearAll().setComputed().applyStatic().applyDynamic(hlRegion())); } - } + }); } public void setDynamicHighlightingEnabled(final boolean enable) { _isDynamicHighlightingEnabled = enable; - updateHighlighting(true); + recomputeHighlighting(); } public boolean isDynamicHighlightingEnabled() { @@ -163,8 +211,8 @@ public void setHighlighter(final SyntaxHighlighterBase newHighlighter) { if (_hl != null) { initHighlighter(); - _hlDebounced = TextViewUtils.makeDebounced(getHandler(), _hl.getHighlightingDelay(), () -> updateHighlighting(true)); - _hlDebounced.run(); + _hlDebounced = TextViewUtils.makeDebounced(getHandler(), _hl.getHighlightingDelay(), this::recomputeHighlightingAsync); + recomputeHighlighting(); } else { _hlDebounced = null; } @@ -221,11 +269,11 @@ public void setLineNumbersEnabled(final boolean enable) { } // Region to highlight - private int[] hlRegion(final Rect rect) { + private int[] hlRegion() { if (_isDynamicHighlightingEnabled) { - final int hlSize = Math.round(HIGHLIGHT_REGION_SIZE * rect.height()) + _hlShiftThreshold; - final int startY = rect.centerY() - hlSize; - final int endY = rect.centerY() + hlSize; + final int hlSize = Math.round(HIGHLIGHT_REGION_SIZE * _hlRect.height()) + _hlShiftThreshold; + final int startY = _hlRect.centerY() - hlSize; + final int endY = _hlRect.centerY() + hlSize; return new int[]{rowStart(startY), rowEnd(endY)}; } else { return new int[]{0, length()}; @@ -292,7 +340,7 @@ public Parcelable onSaveInstanceState() { protected void onVisibilityChanged(@NonNull View changedView, int visibility) { super.onVisibilityChanged(changedView, visibility); if (changedView == this && visibility == View.VISIBLE) { - updateHighlighting(true); + recomputeHighlighting(); } } @@ -472,18 +520,6 @@ public int moveCursorToBeginOfLine(int offset) { return getSelectionStart(); } - // Set selection to fill whole lines - // Returns original selectionStart - public int setSelectionExpandWholeLines() { - final int[] sel = TextViewUtils.getSelection(this); - final CharSequence text = getText(); - setSelection( - TextViewUtils.getLineStart(text, sel[0]), - TextViewUtils.getLineEnd(text, sel[1]) - ); - return sel[0]; - } - public boolean indexesValid(int... indexes) { return GsTextUtils.inRange(0, length(), indexes); } diff --git a/app/src/main/java/net/gsantner/markor/frontend/textview/SyntaxHighlighterBase.java b/app/src/main/java/net/gsantner/markor/frontend/textview/SyntaxHighlighterBase.java index 70092becc..25de2206b 100644 --- a/app/src/main/java/net/gsantner/markor/frontend/textview/SyntaxHighlighterBase.java +++ b/app/src/main/java/net/gsantner/markor/frontend/textview/SyntaxHighlighterBase.java @@ -5,6 +5,7 @@ * https://www.apache.org/licenses/LICENSE-2.0 * #########################################################*/ + package net.gsantner.markor.frontend.textview; import android.graphics.Canvas; @@ -32,6 +33,7 @@ import net.gsantner.markor.format.general.ColorUnderlineSpan; import net.gsantner.markor.format.plaintext.PlaintextSyntaxHighlighter; import net.gsantner.markor.model.AppSettings; +import net.gsantner.opoc.format.GsTextUtils; import net.gsantner.opoc.util.GsContextUtils; import net.gsantner.opoc.wrapper.GsCallback; @@ -44,6 +46,49 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * This is the base class for syntax highlighting, it contains routines for + * managing highlighting spans in the HighlightingEditor. + *

+ * Android's EditText uses a SpannableStringBuilder class to manage it's text, and + * DynamicLayout to to manage the text layout. Both are not very efficient with a large + * number of spans. + *

+ * The approach taken here is to: + * 1. Compute all spans (highlighting) for the text + * 2. Apply only those spans which are currently in the viewport + * 3. Update (remove and reapply) spans when the viewport moves (i.e we scroll) + *

+ * Spans are further divided into two categories: dynamic and static. + * - Dynamic spans are updated as one scrolls, as described above + * - Static spans are applied once and never updated. These are typically used for + * spans which affect the text layout. + * - For example, a span which makes text bigger. + * - Updating these dynamically would make the text jump around as one scrolls + *

+ * Fixup: + * - As the user types we shift all spans to accomodate the changed text. + * - This is done so that dynamically applied spans are applied to the correct region. + * - Fixup is batched and performed only if necessary. + *

+ * Span generation: + * - Derived classes should override generateSpans() to generate all spans + * - New spans are added by calling addSpanGroup() + * - The HighlightingEditor will trigger the generation of spans when the text changes. + * - This is debounced so that changes are batched + * - Span generation is done on a background thread + *

+ * Other performance tips: + * - Performance is heavily dependent on the number of spans applied to the text. + * - Combine related spans into a single span if possible + * - HighlightSpan is a helper class which can be used to create a span with multiple attributes + * - For example, a span which makes text bold and italic + * - Absolutely minimize the number of spans implementing `UpdateLayout` + * - These spans trigger a text layout update when changed in any way + * - Instead consider using a span implementing `StaticSpan` + * - If StaticSpans are present, the text is reflowed after applying them + * - This happens once, and not for each span, which is much more efficient + */ public abstract class SyntaxHighlighterBase { protected final static int LONG_HIGHLIGHTING_DELAY = 2400; @@ -102,7 +147,7 @@ public SyntaxHighlighterBase configure(@Nullable final Paint paint) { // --------------------------------------------------------------------------------------------- /** - * A class representing any span + * A class holding any span */ public static class SpanGroup implements Comparable { int start, end; @@ -113,7 +158,7 @@ public static class SpanGroup implements Comparable { span = o; start = s; end = e; - isStatic = o instanceof UpdateLayout; + isStatic = (span instanceof UpdateLayout || span instanceof StaticSpan); } @Override @@ -126,11 +171,15 @@ private static class ForceUpdateLayout implements UpdateLayout { // Empty class - just implements UpdateLayout } + public interface StaticSpan extends UpdateAppearance { + } + private final ForceUpdateLayout _layoutUpdater; - private final List _groups; + private final List _groups, _groupBuffer; private final NavigableSet _appliedDynamic; private boolean _staticApplied = false; + private int _fixupAfter = -1, _fixupDelta = 0; protected Spannable _spannable; protected final AppSettings _appSettings; @@ -138,8 +187,8 @@ private static class ForceUpdateLayout implements UpdateLayout { public SyntaxHighlighterBase(final AppSettings as) { _appSettings = as; _groups = new ArrayList<>(); + _groupBuffer = new ArrayList<>(); _appliedDynamic = new TreeSet<>(); - _layoutUpdater = new ForceUpdateLayout(); } @@ -154,7 +203,7 @@ public SyntaxHighlighterBase clearAll() { * * @return this */ - public synchronized SyntaxHighlighterBase clearDynamic() { + public SyntaxHighlighterBase clearDynamic() { if (_spannable == null) { return this; } @@ -173,18 +222,24 @@ public synchronized SyntaxHighlighterBase clearDynamic() { * * @return this */ - public synchronized SyntaxHighlighterBase clearStatic() { + public SyntaxHighlighterBase clearStatic() { if (_spannable == null) { return this; } + boolean hasStatic = false; for (int i = _groups.size() - 1; i >= 0; i--) { final SpanGroup group = _groups.get(i); if (group.isStatic) { + hasStatic = true; _spannable.removeSpan(group.span); } } + if (hasStatic) { + reflow(); + } + _staticApplied = false; return this; @@ -197,7 +252,7 @@ public synchronized SyntaxHighlighterBase clearStatic() { * @param spannable Spannable to work on * @return this */ - public synchronized SyntaxHighlighterBase setSpannable(@Nullable final Spannable spannable) { + public SyntaxHighlighterBase setSpannable(@Nullable final Spannable spannable) { if (spannable != _spannable) { _groups.clear(); _appliedDynamic.clear(); @@ -213,7 +268,7 @@ public Spannable getSpannable() { } public boolean hasSpans() { - return _spannable != null && _groups.size() > 0; + return _spannable != null && !_groups.isEmpty(); } /** @@ -223,30 +278,56 @@ public SyntaxHighlighterBase fixup(final int start, final int before, final int return fixup(start + before, count - before); } - // Adjust all spans after a change in the text - /** - * Adjust all currently computed spans. Use to adjust spans after text edited. + * Adjust all currently computed spans so that the spans are still valid after text changes + * We internally buffer / batch these fixes for increased performance * * @param after Apply to spans with region starting after 'after' - * @param delta Apply to + * @param delta How much to shift each span * @return this */ - public synchronized SyntaxHighlighterBase fixup(final int after, final int delta) { - for (int i = _groups.size() - 1; i >= 0; i--) { - final SpanGroup group = _groups.get(i); - // Very simple fixup. If the group is entirely after 'after', adjust it's region - if (group.start <= after) { - // We iterate backwards. As groups are sorted, if start is before after, can break out - break; - } else { - group.start += delta; - group.end += delta; + public SyntaxHighlighterBase fixup(final int after, final int delta) { + if (_fixupAfter == -1) { + _fixupAfter = after; + _fixupDelta = delta; + } else if (isFixupOverlap(after, delta)) { + _fixupAfter = Math.min(_fixupAfter, after); + _fixupDelta += delta; + } else { + applyFixup(); + } + return this; + } + + // Test if fixup region overlaps with the current fixup + private boolean isFixupOverlap(final int after, final int delta) { + return (after >= _fixupAfter && after <= _fixupAfter + Math.abs(_fixupDelta)) || + (_fixupAfter >= after && _fixupAfter <= after + Math.abs(delta)); + } + + private SyntaxHighlighterBase applyFixup() { + if (_fixupAfter >= 0 && _fixupDelta != 0) { + for (int i = _groups.size() - 1; i >= 0; i--) { + final SpanGroup group = _groups.get(i); + // Very simple fixup. If the group is entirely after 'after', adjust it's region + if (group.start <= _fixupAfter) { + // We iterate backwards. As groups are sorted, if start is before after, can break out + break; + } else { + group.start += _fixupDelta; + group.end += _fixupDelta; + } } + clearFixup(); } return this; } + private void clearFixup() { + _fixupAfter = -1; + _fixupDelta = 0; + } + public SyntaxHighlighterBase applyAll() { return applyDynamic().applyStatic(); } @@ -260,51 +341,52 @@ public SyntaxHighlighterBase applyDynamic() { * * @return this */ - public synchronized SyntaxHighlighterBase applyDynamic(final int[] range) { - if (_spannable == null) { - return this; - } - - final int length = _spannable.length(); - if (!TextViewUtils.checkRange(length, range)) { - return this; - } - - for (int i = 0; i < _groups.size(); i++) { - final SpanGroup group = _groups.get(i); - - if (group.isStatic) { - continue; - } + public SyntaxHighlighterBase applyDynamic(final int[] range) { + if (GsTextUtils.isValidSelection(_spannable, range) && range.length >= 2) { + applyFixup(); + final int length = _spannable.length(); + for (int i = 0; i < _groups.size(); i++) { + final SpanGroup group = _groups.get(i); + + if (group.isStatic) { + continue; + } - if (group.start >= range[1]) { - // As we are sorted on start, we can break out after the first group.start > end - break; - } + if (group.start >= range[1]) { + // As we are sorted on start, we can break out after the first group.start > end + break; + } - final boolean valid = group.start >= 0 && group.end > range[0] && group.end <= length; - if (valid && !_appliedDynamic.contains(i)) { - _spannable.setSpan(group.span, group.start, group.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); - _appliedDynamic.add(i); + final boolean valid = group.start >= 0 && group.end > range[0] && group.end <= length; + if (valid && !_appliedDynamic.contains(i)) { + _spannable.setSpan(group.span, group.start, group.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + _appliedDynamic.add(i); + } } } - return this; } - public synchronized SyntaxHighlighterBase applyStatic() { - if (_spannable == null || _staticApplied) { - return this; - } - for (int i = 0; i < _groups.size(); i++) { - final SpanGroup group = _groups.get(i); - if (group.isStatic) { - _spannable.setSpan(group.span, group.start, group.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + public SyntaxHighlighterBase applyStatic() { + if (_spannable != null && !_staticApplied) { + applyFixup(); + + boolean hasStatic = false; + for (final SpanGroup group : _groups) { + if (group.isStatic) { + hasStatic = true; + _spannable.setSpan(group.span, group.start, group.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + if (hasStatic) { + reflow(); } - } - _staticApplied = true; + _staticApplied = true; + } return this; } @@ -314,24 +396,42 @@ public final SyntaxHighlighterBase reflow() { } // Reflow selected region's lines - public final synchronized SyntaxHighlighterBase reflow(final int[] range) { - if (TextViewUtils.checkRange(_spannable, range)) { + public final SyntaxHighlighterBase reflow(final int[] range) { + if (GsTextUtils.isValidSelection(_spannable, range) && range.length >= 2) { _spannable.setSpan(_layoutUpdater, range[0], range[1], Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); _spannable.removeSpan(_layoutUpdater); } return this; } + public final SyntaxHighlighterBase recompute() { + return compute().setComputed(); + } + /** - * Recompute all spans. References to existing spans will be lost. + * Make computed spans current. References to existing spans will be lost. * Caller is responsible for calling 'clear()' before this, if necessary * * @return this */ - public synchronized final SyntaxHighlighterBase recompute() { + public final SyntaxHighlighterBase setComputed() { _groups.clear(); _appliedDynamic.clear(); _staticApplied = false; + _groups.addAll(_groupBuffer); + _groupBuffer.clear(); + clearFixup(); + return this; + } + + /** + * Compute all highlighting spans to a buffer. + * The buffer is not made current until one calls 'setComputed' + * + * @return this + */ + public final SyntaxHighlighterBase compute() { + _groupBuffer.clear(); if (TextUtils.isEmpty(_spannable)) { return this; @@ -340,7 +440,7 @@ public synchronized final SyntaxHighlighterBase recompute() { // Highlighting cannot generate exceptions! try { generateSpans(); - Collections.sort(_groups); // Dramatically improves performance + Collections.sort(_groupBuffer); // Dramatically improves performance } catch (Exception ex) { Log.w(getClass().getName(), ex); } catch (Error er) { @@ -356,7 +456,7 @@ public synchronized final SyntaxHighlighterBase recompute() { protected final void addSpanGroup(final Object span, final int start, final int end) { if (end > start && span != null) { - _groups.add(new SpanGroup(span, start, end)); + _groupBuffer.add(new SpanGroup(span, start, end)); } } 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 00206f96c..89c07d6f5 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 @@ -18,6 +18,7 @@ import android.text.InputFilter; import android.text.Layout; import android.text.Selection; +import android.text.Spannable; import android.text.TextUtils; import android.util.DisplayMetrics; import android.view.View; @@ -29,7 +30,6 @@ import net.gsantner.opoc.format.GsTextUtils; import net.gsantner.opoc.util.GsContextUtils; -import net.gsantner.opoc.wrapper.GsCallback; import java.lang.reflect.Array; import java.util.ArrayList; @@ -47,14 +47,10 @@ private TextViewUtils() { throw new AssertionError(); } - public static int getLineStart(CharSequence s, int start) { - return getLineStart(s, start, 0); - } - - public static int getLineStart(CharSequence s, int start, int minRange) { - int i = start; - if (GsTextUtils.isValidIndex(s, start - 1, minRange)) { - for (; i > minRange; i--) { + public static int getLineStart(final CharSequence s, final int sel) { + int i = sel; + if (GsTextUtils.isValidSelection(s, i)) { + for (; i > 0; i--) { if (s.charAt(i - 1) == '\n') { break; } @@ -64,14 +60,10 @@ public static int getLineStart(CharSequence s, int start, int minRange) { return i; } - public static int getLineEnd(CharSequence s, int start) { - return getLineEnd(s, start, s.length()); - } - - public static int getLineEnd(CharSequence s, int start, int maxRange) { - int i = start; - if (GsTextUtils.isValidIndex(s, start, maxRange - 1)) { - for (; i < maxRange; i++) { + public static int getLineEnd(final CharSequence s, final int sel) { + int i = sel; + if (GsTextUtils.isValidSelection(s, i)) { + for (; i < s.length(); i++) { if (s.charAt(i) == '\n') { break; } @@ -121,33 +113,20 @@ public static int[] getSelection(final TextView text) { // CharSequence must be an instance of _Spanned_ public static int[] getSelection(final CharSequence text) { + if (text == null) { + return new int[]{-1, -1}; + } - final int selectionStart = Selection.getSelectionStart(text); - final int selectionEnd = Selection.getSelectionEnd(text); + final int start = Selection.getSelectionStart(text); + final int end = Selection.getSelectionEnd(text); - if (selectionEnd >= selectionStart) { - return new int[]{selectionStart, selectionEnd}; + if (end >= start) { + return new int[]{start, end}; } else { - return new int[]{selectionEnd, selectionStart}; + return new int[]{end, start}; } } - public static void withKeepSelection(final Editable text, final GsCallback.a2 action) { - final int[] sel = TextViewUtils.getSelection(text); - final int[] selStart = TextViewUtils.getLineOffsetFromIndex(text, sel[0]); - final int[] selEnd = TextViewUtils.getLineOffsetFromIndex(text, sel[1]); - - action.callback(sel[0], sel[1]); - - Selection.setSelection(text, - TextViewUtils.getIndexFromLineOffset(text, selStart), - TextViewUtils.getIndexFromLineOffset(text, selEnd)); - } - - public static void withKeepSelection(final Editable text, final GsCallback.a0 action) { - withKeepSelection(text, (start, end) -> action.callback()); - } - public static String getSelectedText(final CharSequence text) { final int[] sel = getSelection(text); return (sel[0] >= 0 && sel[1] >= 0) ? text.subSequence(sel[0], sel[1]).toString() : ""; @@ -158,7 +137,7 @@ public static String getSelectedText(final TextView text) { } public static int[] getLineSelection(final CharSequence text, final int[] sel) { - return sel != null && sel.length >= 2 ? new int[]{getLineStart(text, sel[0]), getLineEnd(text, sel[1])} : null; + return sel != null && sel.length >= 2 ? new int[]{getLineStart(text, sel[0]), getLineEnd(text, sel[1])} : new int[]{-1, -1}; } public static int[] getLineSelection(final CharSequence text, final int sel) { @@ -173,7 +152,6 @@ public static int[] getLineSelection(final CharSequence seq) { return getLineSelection(seq, getSelection(seq)); } - /** * Get lines of text in which sel[0] -> sel[1] is contained **/ @@ -189,16 +167,13 @@ public static String getSelectedLines(final CharSequence seq) { * Get lines of text in which sel[0] -> sel[1] is contained **/ public static String getSelectedLines(final CharSequence seq, final int... sel) { - if (sel == null || sel.length == 0) { + if (sel != null && sel.length > 0 && GsTextUtils.isValidSelection(seq, sel)) { + return seq.subSequence(getLineStart(seq, sel[0]), getLineEnd(seq, sel[1])).toString(); + } else { return ""; } - - final int start = Math.min(Math.max(sel[0], 0), seq.length()); - final int end = Math.min(Math.max(start, sel[sel.length - 1]), seq.length()); - return seq.subSequence(getLineStart(seq, start), getLineEnd(seq, end)).toString(); } - /** * Convert a char index to a line index + offset from end of line * @@ -206,14 +181,40 @@ public static String getSelectedLines(final CharSequence seq, final int... sel) * @param p position in text * @return int[2] where index 0 is line and index 1 is position from end of line */ - public static int[] getLineOffsetFromIndex(final CharSequence s, int p) { - p = Math.min(Math.max(p, 0), s.length()); - final int line = GsTextUtils.countChars(s, 0, p, '\n')[0]; - final int offset = getLineEnd(s, p) - p; + public static int[][] getLineOffsetFromIndex(final CharSequence text, final int ... sel) { + final int[][] offsets = new int[sel.length][2]; + + for (int i = 0; i < sel.length; i++) { + offsets[i] = new int[] {-1, -1}; + final int p = sel[i]; + if (p >= 0 && p <= text.length()) { + offsets[i][0] = GsTextUtils.countChars(text, 0, p, '\n')[0]; + offsets[i][1] = getLineEnd(text, p) - p; + } + } + + return offsets; + } + + public static void setSelectionFromOffsets(final TextView text, final int[][] offsets) { + setSelectionFromOffsets((Spannable) text.getText(), offsets); + } - return new int[]{line, offset}; + public static void setSelectionFromOffsets(final Spannable text, final int[][] offsets) { + if (offsets != null && offsets.length >= 2 && + offsets[0] != null && offsets[0].length == 2 && + offsets[1] != null && offsets[1].length == 2 && + text != null + ) { + final int start = getIndexFromLineOffset(text, offsets[0]); + final int end = getIndexFromLineOffset(text, offsets[1]); + if (GsTextUtils.isValidSelection(text, start, end)) { + Selection.setSelection(text, start, end); + } + } } + public static int getIndexFromLineOffset(final CharSequence s, final int[] le) { return getIndexFromLineOffset(s, le[0], le[1]); } @@ -227,6 +228,10 @@ public static int getIndexFromLineOffset(final CharSequence s, final int[] le) { * @return index in s */ public static int getIndexFromLineOffset(final CharSequence s, final int l, final int e) { + if (l < 0 || e < 0) { + return -1; + } + int i = 0, count = 0; if (s != null) { if (l > 0) { @@ -302,15 +307,13 @@ public static void showSelection(final TextView text, final int start, final int final int _start = Math.min(start, end); final int _end = Math.max(start, end); - if (start < 0 || end > text.length()) { + if (_start < 0 || _end > text.length()) { return; } final int lineStart = TextViewUtils.getLineStart(text.getText(), _start); final Rect viewSize = new Rect(); - if (!text.getLocalVisibleRect(viewSize)) { - return; - } + text.getLocalVisibleRect(viewSize); // Region in Y // ------------------------------------------------------------ @@ -340,8 +343,7 @@ public static void showSelection(final TextView text, final int start, final int region.left = startLeft - halfWidth; region.right = startLeft + halfWidth; - // Call in post to try to make sure we run after any pending actions - text.post(() -> text.requestRectangleOnScreen(region)); + text.requestRectangleOnScreen(region); } public static void showSelection(final TextView text) { @@ -357,15 +359,13 @@ public static void setSelectionAndShow(final EditText edit, boolean setSelection final int end = sel.length > 1 ? sel[1] : start; if (GsTextUtils.inRange(0, edit.length(), start, end)) { - edit.post(() -> { - if (!edit.hasFocus() && edit.getVisibility() != View.GONE) { - edit.requestFocus(); - } - if (setSelection) { - edit.setSelection(start, end); - } - edit.postDelayed(() -> showSelection(edit, start, end), 250); - }); + if (!edit.hasFocus() && edit.getVisibility() != View.GONE) { + edit.requestFocus(); + } + if (setSelection) { + edit.setSelection(start, end); + } + showSelection(edit, start, end); } } @@ -735,15 +735,6 @@ public static String toString(final CharSequence source, int start, int end) { return new String(buf); } - // Check if a range is valid - public static boolean checkRange(final CharSequence seq, final int... indices) { - return checkRange(seq.length(), indices); - } - - public static boolean checkRange(final int length, final int... indices) { - return indices != null && indices.length >= 2 && GsTextUtils.inRange(0, length, indices) && indices[1] > indices[0]; - } - public static boolean isViewVisible(final View view) { if (view == null || !view.isShown()) { return false; diff --git a/app/src/main/java/net/gsantner/markor/model/AppSettings.java b/app/src/main/java/net/gsantner/markor/model/AppSettings.java index 17d35f153..e07d02a73 100644 --- a/app/src/main/java/net/gsantner/markor/model/AppSettings.java +++ b/app/src/main/java/net/gsantner/markor/model/AppSettings.java @@ -87,7 +87,7 @@ public boolean isPreferViewMode() { } public void setNotebookDirectory(final File file) { - setString(R.string.pref_key__notebook_directory, file.getAbsolutePath()); + setString(R.string.pref_key__notebook_directory, Document.getPath(file)); } public File getNotebookDirectory() { @@ -106,7 +106,7 @@ public File getQuickNoteFile() { } public void setQuickNoteFile(final File file) { - setString(R.string.pref_key__quicknote_filepath, file.getAbsolutePath()); + setString(R.string.pref_key__quicknote_filepath, Document.getPath(file)); } public File getDefaultQuickNoteFile() { @@ -118,7 +118,7 @@ public File getTodoFile() { } public void setTodoFile(final File file) { - setString(R.string.pref_key__todo_filepath, file.getAbsolutePath()); + setString(R.string.pref_key__todo_filepath, Document.getPath(file)); } public File getDefaultTodoFile() { @@ -132,7 +132,7 @@ public File getSnippetsDirectory() { } public void setSnippetDirectory(final File folder) { - setString(R.string.pref_key__snippet_directory_path, folder.getAbsolutePath()); + setString(R.string.pref_key__snippet_directory_path, Document.getPath(folder)); } public String getFontFamily() { @@ -340,15 +340,16 @@ public void addRecentFile(final File file) { if (!listFileInRecents(file)) { return; } + final String path = Document.getPath(file); if (!file.equals(getTodoFile()) && !file.equals(getQuickNoteFile())) { ArrayList recent = getRecentDocuments(); - recent.add(0, file.getAbsolutePath()); - recent.remove(getTodoFile().getAbsolutePath()); - recent.remove(getQuickNoteFile().getAbsolutePath()); + recent.add(0, path); + recent.remove(Document.getPath(getTodoFile())); + recent.remove(Document.getPath(getQuickNoteFile())); recent.remove(""); recent.remove(null); - setInt(file.getAbsolutePath(), getInt(file.getAbsolutePath(), 0, _prefCache) + 1, _prefCache); + setInt(path, getInt(path, 0, _prefCache) + 1, _prefCache); setRecentDocuments(recent); } ShortcutUtils.setShortcuts(_context); @@ -357,8 +358,8 @@ public void addRecentFile(final File file) { public void setFavouriteFiles(final Collection files) { final Set set = new LinkedHashSet<>(); for (final File f : files) { - if (f != null && (f.exists() || GsFileBrowserListAdapter.isVirtualStorage(f))) { - set.add(f.getAbsolutePath()); + if (f != null && (f.exists() || GsFileBrowserListAdapter.isVirtualFolder(f))) { + set.add(Document.getPath(f)); } } setStringList(R.string.pref_key__favourite_files, GsCollectionUtils.map(set, p -> p)); @@ -422,8 +423,8 @@ public void setLastViewPosition(File file, int scrollX, int scrollY) { return; } if (!file.equals(getTodoFile()) && !file.equals(getQuickNoteFile())) { - setInt(PREF_PREFIX_VIEW_SCROLL_X + file.getAbsolutePath(), scrollX, _prefCache); - setInt(PREF_PREFIX_VIEW_SCROLL_Y + file.getAbsolutePath(), scrollY, _prefCache); + setInt(PREF_PREFIX_VIEW_SCROLL_X + Document.getPath(file), scrollX, _prefCache); + setInt(PREF_PREFIX_VIEW_SCROLL_Y + Document.getPath(file), scrollY, _prefCache); } } @@ -550,14 +551,14 @@ public int getLastViewPositionX(File file) { if (file == null || !file.exists()) { return -1; } - return getInt(PREF_PREFIX_VIEW_SCROLL_X + file.getAbsolutePath(), -3, _prefCache); + return getInt(PREF_PREFIX_VIEW_SCROLL_X + Document.getPath(file), -3, _prefCache); } public int getLastViewPositionY(File file) { if (file == null || !file.exists()) { return -1; } - return getInt(PREF_PREFIX_VIEW_SCROLL_Y + file.getAbsolutePath(), -3, _prefCache); + return getInt(PREF_PREFIX_VIEW_SCROLL_Y + Document.getPath(file), -3, _prefCache); } private List getPopularDocumentsSorted() { @@ -600,7 +601,7 @@ public static Set getFileSet(final List paths) { final Set set = new LinkedHashSet<>(); for (final String fp : paths) { final File f = new File(fp); - if (f.exists() || GsFileBrowserListAdapter.isVirtualStorage(f)) { + if (f.exists() || GsFileBrowserListAdapter.isVirtualFolder(f)) { set.add(f); } } @@ -813,16 +814,16 @@ public int getTabWidth() { } public boolean listFileInRecents(File file) { - return getBool(file.getAbsolutePath() + "_list_in_recents", true); + return getBool(Document.getPath(file) + "_list_in_recents", true); } public void setListFileInRecents(File file, boolean value) { - setBool(file.getAbsolutePath() + "_list_in_recents", value); + setBool(Document.getPath(file) + "_list_in_recents", value); if (!value) { ArrayList recent = getRecentDocuments(); - if (recent.contains(file.getAbsolutePath())) { - recent.remove(file.getAbsolutePath()); + if (recent.contains(Document.getPath(file))) { + recent.remove(Document.getPath(file)); setRecentDocuments(recent); } } @@ -837,11 +838,11 @@ public ArrayList getFilesTaggedWith(String tag) { }*/ public int getRating(File file) { - return getInt(file.getAbsolutePath() + "_rating", 0); + return getInt(Document.getPath(file) + "_rating", 0); } public void setRating(File file, int value) { - setInt(file.getAbsolutePath() + "_rating", value); + setInt(Document.getPath(file) + "_rating", value); } public boolean isEditorLineBreakingEnabled() { @@ -931,7 +932,7 @@ public void setNewFileDialogLastUsedType(final int format) { } public void setFileBrowserLastBrowsedFolder(File f) { - setString(R.string.pref_key__file_browser_last_browsed_folder, f.getAbsolutePath()); + setString(R.string.pref_key__file_browser_last_browsed_folder, Document.getPath(f)); } public File getFileBrowserLastBrowsedFolder() { @@ -1068,6 +1069,13 @@ public void saveTitleFormat(final String format, final int maxCount) { setString(R.string.pref_key__title_format_list, toJsonString(updated)); } + public void setFormatShareAsLink(final boolean asLink) { + setBool(R.string.pref_key__format_share_as_link, asLink); + } + + public boolean getFormatShareAsLink() { + return getBool(R.string.pref_key__format_share_as_link, true); + } private static String mapToJsonString(final Map map) { return new JSONObject(map).toString(); diff --git a/app/src/main/java/net/gsantner/markor/model/Document.java b/app/src/main/java/net/gsantner/markor/model/Document.java index 504ce06f6..e4003c519 100644 --- a/app/src/main/java/net/gsantner/markor/model/Document.java +++ b/app/src/main/java/net/gsantner/markor/model/Document.java @@ -51,12 +51,15 @@ public class Document implements Serializable { public static final String EXTRA_DOCUMENT = "EXTRA_DOCUMENT"; // Document public static final String EXTRA_FILE = "EXTRA_FILE"; // java.io.File public static final String EXTRA_FILE_LINE_NUMBER = "EXTRA_FILE_LINE_NUMBER"; // int + public static final String EXTRA_DO_PREVIEW = "EXTRA_DO_PREVIEW"; public static final int EXTRA_FILE_LINE_NUMBER_LAST = -919385553; // Flag for last line - private final File _file; - private final String _fileExtension; - private String _title = ""; - private String _path = ""; + // Exposed properties + public final File file; + public final String extension; + public final String title; + public final String path; + private long _modTime = -1; // The file's mod time when it was last touched by this document private long _touchTime = -1; // The last time this document touched the file private GsFileUtils.FileInfo _fileInfo; @@ -68,15 +71,15 @@ public class Document implements Serializable { private long _lastHash = 0; private int _lastLength = -1; - public Document(@NonNull final File file) { - _file = file; - _path = _file.getAbsolutePath(); - _title = GsFileUtils.getFilenameWithoutExtension(_file); - _fileExtension = GsFileUtils.getFilenameExtension(_file); + public Document(@NonNull final File f) { + path = getPath(f); + file = new File(path); + title = GsFileUtils.getFilenameWithoutExtension(file); + extension = GsFileUtils.getFilenameExtension(file); // Set initial format for (final FormatRegistry.Format format : FormatRegistry.FORMATS) { - if (format.converter == null || format.converter.isFileOutOfThisFormat(_file)) { + if (format.converter == null || format.converter.isFileOutOfThisFormat(file)) { setFormat(format.format); break; } @@ -84,7 +87,13 @@ public Document(@NonNull final File file) { } public static String getPath(final File file) { - return file != null ? file.getAbsolutePath() : ""; + try { + return file.getCanonicalPath(); + } catch (IOException e) { + return file.getAbsolutePath(); + } catch (NullPointerException e) { + return ""; + } } // Get a default file @@ -94,15 +103,6 @@ public static Document getDefault(final Context context) { return new Document(random); } - public String getPath() { - return _path; - } - - @NonNull - public File getFile() { - return _file; - } - private void initModTimePref() { // We do not do this in constructor as we want to init after deserialization too if (_modTimePref == null) { @@ -112,13 +112,13 @@ private void initModTimePref() { private long getGlobalTouchTime() { initModTimePref(); - return _modTimePref.getLong(_file.getAbsolutePath(), 0); + return _modTimePref.getLong(file.getAbsolutePath(), 0); } private void setGlobalTouchTime() { initModTimePref(); _touchTime = System.currentTimeMillis(); - _modTimePref.edit().putLong(_file.getAbsolutePath(), _touchTime).apply(); + _modTimePref.edit().putLong(file.getAbsolutePath(), _touchTime).apply(); } public void resetChangeTracking() { @@ -135,37 +135,29 @@ public boolean hasFileChangedSinceLastLoad() { public long fileModTime() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return Files.readAttributes(_file.toPath(), BasicFileAttributes.class).lastModifiedTime().toMillis(); + return Files.readAttributes(file.toPath(), BasicFileAttributes.class).lastModifiedTime().toMillis(); } } catch (IOException ignored) { } - return _file.lastModified(); + return file.lastModified(); } public long fileBytes() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - return Files.readAttributes(_file.toPath(), BasicFileAttributes.class).size(); + return Files.readAttributes(file.toPath(), BasicFileAttributes.class).size(); } } catch (Exception ignored) { } - return _file.length(); - } - - public String getTitle() { - return _title; - } - - public String getName() { - return _file.getName(); + return file.length(); } @Override public boolean equals(Object obj) { if (obj instanceof Document) { Document other = ((Document) obj); - return equalsc(_file, other._file) - && equalsc(getTitle(), other.getTitle()) + return equalsc(file, other.file) + && equalsc(title, other.title) && (getFormat() == other.getFormat()); } return super.equals(obj); @@ -176,7 +168,7 @@ private static boolean equalsc(Object o1, Object o2) { } public String getFileExtension() { - return _fileExtension; + return extension; } @StringRes @@ -193,11 +185,11 @@ public static boolean isEncrypted(File file) { } public boolean isBinaryFileNoTextLoading() { - return _file != null && FormatRegistry.CONVERTER_EMBEDBINARY.isFileOutOfThisFormat(_file); + return file != null && FormatRegistry.CONVERTER_EMBEDBINARY.isFileOutOfThisFormat(file); } public boolean isEncrypted() { - return isEncrypted(_file); + return isEncrypted(file); } private void setContentHash(final CharSequence s) { @@ -218,27 +210,27 @@ String loadContent(final Context context) { content = ""; } else if (isEncrypted() && (pw = getPasswordWithWarning(context)) != null) { try { - final byte[] encryptedContext = GsFileUtils.readCloseStreamWithSize(new FileInputStream(_file), (int) _file.length()); + final byte[] encryptedContext = GsFileUtils.readCloseStreamWithSize(new FileInputStream(file), (int) file.length()); if (encryptedContext.length > JavaPasswordbasedCryption.Version.NAME_LENGTH) { content = JavaPasswordbasedCryption.getDecryptedText(encryptedContext, pw); } else { content = new String(encryptedContext, StandardCharsets.UTF_8); } } catch (FileNotFoundException e) { - Log.e(Document.class.getName(), "loadDocument: File " + _file + " not found."); + Log.e(Document.class.getName(), "loadDocument: File " + file + " not found."); content = ""; } catch (JavaPasswordbasedCryption.EncryptionFailedException | IllegalArgumentException e) { Toast.makeText(context, R.string.could_not_decrypt_file_content_wrong_password_or_is_the_file_maybe_not_encrypted, Toast.LENGTH_LONG).show(); - Log.e(Document.class.getName(), "loadDocument: decrypt failed for File " + _file + ". " + e.getMessage(), e); + Log.e(Document.class.getName(), "loadDocument: decrypt failed for File " + file + ". " + e.getMessage(), e); content = ""; } } else { // We try to load 2x. If both times fail, we return null - Pair result = GsFileUtils.readTextFileFast(_file); + Pair result = GsFileUtils.readTextFileFast(file); if (result.second.ioError) { - Log.i(Document.class.getName(), "loadDocument: File " + _file + " read error, trying again."); - result = GsFileUtils.readTextFileFast(_file); + Log.i(Document.class.getName(), "loadDocument: File " + file + " read error, trying again."); + result = GsFileUtils.readTextFileFast(file); } content = result.first; _fileInfo = result.second; @@ -247,7 +239,7 @@ String loadContent(final Context context) { if (MainActivity.IS_DEBUG_ENABLED) { AppSettings.appendDebugLog( "\n\n\n--------------\nLoaded document, filepattern " - + getName().replaceAll(".*\\.", "-") + + title.replaceAll(".*\\.", "-") + ", chars: " + content.length() + " bytes:" + content.getBytes().length + "(" + GsFileUtils.getReadableFileSize(content.getBytes().length, true) + "). Language >" + Locale.getDefault() @@ -258,7 +250,7 @@ String loadContent(final Context context) { // Force next load on failure setContentHash(null); resetChangeTracking(); - Log.i(Document.class.getName(), "loadDocument: File " + _file + " read error, could not load file."); + Log.i(Document.class.getName(), "loadDocument: File " + file + " read error, could not load file."); return null; } else { // Also set hash and time on load - should prevent unnecessary saves @@ -282,7 +274,7 @@ private static char[] getPasswordWithWarning(final Context context) { } public boolean testCreateParent() { - return testCreateParent(_file); + return testCreateParent(file); } public static boolean testCreateParent(final File file) { @@ -328,9 +320,9 @@ public synchronized boolean saveContent(final Activity context, final CharSequen } cu = cu != null ? cu : new MarkorContextUtils(context); - final boolean isContentResolverProxyFile = cu.isContentResolverProxyFile(_file); - if (cu.isUnderStorageAccessFolder(context, _file, false) || isContentResolverProxyFile) { - cu.writeFile(context, _file, false, (fileOpened, fos) -> { + final boolean isContentResolverProxyFile = cu.isContentResolverProxyFile(file); + if (cu.isUnderStorageAccessFolder(context, file, false) || isContentResolverProxyFile) { + cu.writeFile(context, file, false, (fileOpened, fos) -> { try { if (_fileInfo != null && _fileInfo.hasBom) { fos.write(0xEF); @@ -341,7 +333,7 @@ public synchronized boolean saveContent(final Activity context, final CharSequen // Also overwrite content resolver proxy file in addition to writing back to the origin if (isContentResolverProxyFile) { - GsFileUtils.writeFile(_file, contentAsBytes, _fileInfo); + GsFileUtils.writeFile(file, contentAsBytes, _fileInfo); } } catch (Exception e) { @@ -351,20 +343,20 @@ public synchronized boolean saveContent(final Activity context, final CharSequen success = true; } else { // Try write 2x - success = GsFileUtils.writeFile(_file, contentAsBytes, _fileInfo); + success = GsFileUtils.writeFile(file, contentAsBytes, _fileInfo); if (!success || fileBytes() < contentAsBytes.length) { - success = GsFileUtils.writeFile(_file, contentAsBytes, _fileInfo); + success = GsFileUtils.writeFile(file, contentAsBytes, _fileInfo); } } final long size = fileBytes(); if (fileBytes() < contentAsBytes.length) { success = false; - Log.i(Document.class.getName(), "File write failed; size = " + size + "; length = " + contentAsBytes.length + "; file=" + _file); + Log.i(Document.class.getName(), "File write failed; size = " + size + "; length = " + contentAsBytes.length + "; file=" + file); } } catch (JavaPasswordbasedCryption.EncryptionFailedException e) { - Log.e(Document.class.getName(), "writeContent: encrypt failed for File " + getPath() + ". " + e.getMessage(), e); + Log.e(Document.class.getName(), "writeContent: encrypt failed for File " + path + ". " + e.getMessage(), e); Toast.makeText(context, R.string.could_not_encrypt_file_content_the_file_was_not_saved, Toast.LENGTH_LONG).show(); success = false; } @@ -374,7 +366,7 @@ public synchronized boolean saveContent(final Activity context, final CharSequen _modTime = fileModTime(); setGlobalTouchTime(); } else { - Log.i(Document.class.getName(), "File write failed, size = " + fileBytes() + "; file=" + _file); + Log.i(Document.class.getName(), "File write failed, size = " + fileBytes() + "; file=" + file); } return success; diff --git a/app/src/main/java/net/gsantner/markor/util/MarkorContextUtils.java b/app/src/main/java/net/gsantner/markor/util/MarkorContextUtils.java index ab5664349..e9ff4aa65 100644 --- a/app/src/main/java/net/gsantner/markor/util/MarkorContextUtils.java +++ b/app/src/main/java/net/gsantner/markor/util/MarkorContextUtils.java @@ -80,7 +80,7 @@ public T createLauncherDesktopShortcut(final Context @RequiresApi(api = Build.VERSION_CODES.KITKAT) @SuppressWarnings("deprecation") public PrintJob printOrCreatePdfFromWebview(final WebView webview, Document document, boolean... landscape) { - String jobName = String.format("%s (%s)", document.getTitle(), webview.getContext().getString(R.string.app_name_real)); + String jobName = String.format("%s (%s)", document.title, webview.getContext().getString(R.string.app_name_real)); return super.print(webview, jobName, landscape); } diff --git a/app/src/main/java/net/gsantner/opoc/format/GsTextUtils.java b/app/src/main/java/net/gsantner/opoc/format/GsTextUtils.java index 6cd235569..bcc013773 100644 --- a/app/src/main/java/net/gsantner/opoc/format/GsTextUtils.java +++ b/app/src/main/java/net/gsantner/opoc/format/GsTextUtils.java @@ -430,7 +430,11 @@ public static boolean isNewLine(CharSequence source, int start, int end) { } public static boolean isValidIndex(final CharSequence s, final int... indices) { - return s != null && inRange(0, s.length() - 1, indices); + return s != null && indices != null && inRange(0, s.length() - 1, indices); + } + + public static boolean isValidSelection(final CharSequence s, final int... indices) { + return s != null && indices != null && inRange(0, s.length(), indices); } // Checks if all values are in [min, max] _inclusive_ diff --git a/app/src/main/java/net/gsantner/opoc/frontend/GsSearchOrCustomTextDialog.java b/app/src/main/java/net/gsantner/opoc/frontend/GsSearchOrCustomTextDialog.java index 9d435cd6e..9d79309ad 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/GsSearchOrCustomTextDialog.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/GsSearchOrCustomTextDialog.java @@ -359,9 +359,15 @@ public static void showMultiChoiceDialogWithSearchFilterUI(final Activity activi if (dopt.isSearchEnabled) { if (dopt.isSoftInputVisible) { win.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE); + searchEditText.postDelayed(() -> win.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_UNCHANGED), 500); + searchEditText.requestFocus(); } else { win.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE | WindowManager.LayoutParams.SOFT_INPUT_STATE_HIDDEN); } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + win.setDecorFitsSystemWindows(true); + } } win.setLayout( @@ -377,10 +383,6 @@ public static void showMultiChoiceDialogWithSearchFilterUI(final Activity activi neutralButton.setOnClickListener((button) -> dopt.neutralButtonCallback.callback(dialog)); } - if (dopt.isSearchEnabled) { - searchEditText.requestFocus(); - } - if (dopt.defaultText != null) { listAdapter.filter(searchEditText.getText()); } @@ -543,7 +545,6 @@ private static View makeSearchView(final Context context, final DialogOptions do final AppCompatEditText searchEditText = new AppCompatEditText(context); searchEditText.setText(dopt.defaultText); searchEditText.setSingleLine(true); - searchEditText.setMaxLines(1); searchEditText.setTextColor(dopt.textColor); searchEditText.setHintTextColor((dopt.textColor & 0x00FFFFFF) | 0x99000000); searchEditText.setHint(dopt.searchHintText); diff --git a/app/src/main/java/net/gsantner/opoc/frontend/base/GsFragmentBase.java b/app/src/main/java/net/gsantner/opoc/frontend/base/GsFragmentBase.java index 8a7448671..3cdebd5ef 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/base/GsFragmentBase.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/base/GsFragmentBase.java @@ -130,10 +130,10 @@ public String getAppLanguage() { /** * This will be called when this fragment gets the first time visible */ - public void onFragmentFirstTimeVisible() { + protected void onFragmentFirstTimeVisible() { } - private synchronized void checkRunFirstTimeVisible() { + private void checkRunFirstTimeVisible() { if (_fragmentFirstTimeVisible && isVisible() && isResumed()) { _fragmentFirstTimeVisible = false; onFragmentFirstTimeVisible(); @@ -173,7 +173,7 @@ public void onResume() { super.onResume(); final View view = getView(); if (view != null) { - view.postDelayed(this::checkRunFirstTimeVisible, 200); + view.post(this::checkRunFirstTimeVisible); // Add any remaining tasks while (!_postTasks.isEmpty()) { view.post(_postTasks.remove()); diff --git a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserDialog.java b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserDialog.java index 6134b3639..b0609cbd7 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserDialog.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserDialog.java @@ -21,6 +21,7 @@ import android.app.Dialog; import android.content.DialogInterface; import android.os.Bundle; +import android.text.TextUtils; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -30,6 +31,7 @@ import android.widget.TextView; import androidx.annotation.ColorRes; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.Toolbar; import androidx.core.content.ContextCompat; @@ -80,6 +82,20 @@ public static GsFileBrowserDialog newInstance(final GsFileBrowserOptions.Options //######################## //## Methods //######################## + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + return new Dialog(getActivity()) { + @Override + public void onBackPressed() { + if (_filesystemViewerAdapter == null || !_filesystemViewerAdapter.goBack()) { + this.dismiss(); + } + } + }; + } + @Override public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View root = inflater.inflate(R.layout.opoc_filesystem_dialog, container, false); @@ -127,6 +143,7 @@ public void onViewCreated(final View root, final @Nullable Bundle savedInstanceS _toolBar.setTitleTextColor(rcolor(_dopt.titleTextColor)); _toolBar.setTitle(_dopt.titleText); _toolBar.setSubtitleTextColor(rcolor(_dopt.secondaryTextColor)); + setSubtitleApprearance(_toolBar); _homeButton.setImageResource(_dopt.homeButtonImage); _homeButton.setVisibility(_dopt.homeButtonEnable ? View.VISIBLE : View.GONE); @@ -149,9 +166,9 @@ public void onViewCreated(final View root, final @Nullable Bundle savedInstanceS root.setBackgroundColor(rcolor(_dopt.backgroundColor)); - // final LinearLayoutManager lam = (LinearLayoutManager) _recyclerList.getLayoutManager(); - // final DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(activity, lam.getOrientation()); - // _recyclerList.addItemDecoration(dividerItemDecoration); + final LinearLayoutManager lam = (LinearLayoutManager) _recyclerList.getLayoutManager(); + final DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(activity, lam.getOrientation()); + _recyclerList.addItemDecoration(dividerItemDecoration); _recyclerList.setItemViewCacheSize(20); _filesystemViewerAdapter = new GsFileBrowserListAdapter(_dopt, activity); @@ -229,6 +246,8 @@ private void showNewDirDialog() { dopt.textColor = rcolor(_dopt.primaryTextColor); dopt.searchHintText = android.R.string.untitled; dopt.searchInputFilter = GsContextUtils.instance.makeFilenameInputFilter(); + dopt.isSearchEnabled = true; + dopt.isSoftInputVisible = true; dopt.callback = name -> _filesystemViewerAdapter.createDirectoryHere(name); GsSearchOrCustomTextDialog.showMultiChoiceDialogWithSearchFilterUI(activity, dopt); @@ -280,7 +299,7 @@ public void onFsViewerDoUiUpdate(GsFileBrowserListAdapter adapter) { _callback.onFsViewerDoUiUpdate(adapter); } if (adapter.getCurrentFolder() != null) { - _toolBar.setSubtitle(adapter.getCurrentFolder().getName()); + _toolBar.setSubtitle(adapter.getCurrentFolder().getPath()); } } @@ -299,4 +318,30 @@ public void onStart() { w.setLayout(WindowManager.LayoutParams.MATCH_PARENT, WindowManager.LayoutParams.MATCH_PARENT); } } + + private static void setSubtitleApprearance(final Toolbar toolbar) { + final String test = "__%%SUBTITLE%%__"; + toolbar.setSubtitle(test); + + for (int i = 0; i < toolbar.getChildCount(); i++) { + final View child = toolbar.getChildAt(i); + if (child instanceof TextView) { + final TextView tv = (TextView) child; + if (test.contentEquals(tv.getText())) { + + tv.setEllipsize(TextUtils.TruncateAt.START); + tv.setSingleLine(true); + final Toolbar.LayoutParams params = new Toolbar.LayoutParams( + Toolbar.LayoutParams.MATCH_PARENT, + Toolbar.LayoutParams.WRAP_CONTENT + ); + tv.setLayoutParams(params); + + break; + } + } + } + + toolbar.setSubtitle(""); + } } diff --git a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserFragment.java b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserFragment.java index 742285adc..f63af2207 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserFragment.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserFragment.java @@ -91,6 +91,7 @@ public static GsFileBrowserFragment newInstance() { private Menu _fragmentMenu; private MarkorContextUtils _cu; private Toolbar _toolbar; + private File _lastSelectedFile; //######################## //## Methods @@ -183,6 +184,7 @@ private void checkOptions() { @Override public void onFsViewerSelected(String request, File file, final Integer lineNumber) { if (_callback != null) { + _filesystemViewerAdapter.showFileAfterNextLoad(file); _callback.onFsViewerSelected(_dopt.requestId, file, lineNumber); } } @@ -283,8 +285,7 @@ public void onFsViewerItemLongPressed(File file, boolean doSelectMultiple) { @Override public boolean onBackPressed() { - if (_filesystemViewerAdapter != null && _filesystemViewerAdapter.canGoUp() && !_filesystemViewerAdapter.isCurrentFolderHome()) { - _filesystemViewerAdapter.goUp(); + if (_filesystemViewerAdapter != null && _filesystemViewerAdapter.goBack()) { return true; } return super.onBackPressed(); @@ -466,9 +467,7 @@ public boolean onOptionsItemSelected(final MenuItem item) { if (confirmed) { Runnable deleter = () -> { WrMarkorSingleton.getInstance().deleteSelectedItems(currentSelection, getContext()); - _recyclerList.post(() -> { - _filesystemViewerAdapter.reloadCurrentFolder(); - }); + _recyclerList.post(() -> _filesystemViewerAdapter.reloadCurrentFolder()); }; new Thread(deleter).start(); } @@ -550,13 +549,13 @@ public void clearSelection() { /////////////// public void askForDeletingFilesRecursive(WrConfirmDialog.ConfirmDialogCallback confirmCallback) { final ArrayList itemsToDelete = new ArrayList<>(_filesystemViewerAdapter.getCurrentSelection()); - StringBuilder message = new StringBuilder(String.format(getString(R.string.do_you_really_want_to_delete_this_witharg), getResources().getQuantityString(R.plurals.documents, itemsToDelete.size())) + "\n\n"); + final StringBuilder message = new StringBuilder(String.format(getString(R.string.do_you_really_want_to_delete_this_witharg), getResources().getQuantityString(R.plurals.documents, itemsToDelete.size())) + "\n\n"); - for (File f : itemsToDelete) { - message.append("\n").append(f.getAbsolutePath()); + for (final File f : itemsToDelete) { + message.append("\n").append(f.getName()); } - WrConfirmDialog confirmDialog = WrConfirmDialog.newInstance(getString(R.string.confirm_delete), message.toString(), itemsToDelete, confirmCallback); + final WrConfirmDialog confirmDialog = WrConfirmDialog.newInstance(getString(R.string.confirm_delete), message.toString(), itemsToDelete, confirmCallback); confirmDialog.show(getChildFragmentManager(), WrConfirmDialog.FRAGMENT_TAG); } diff --git a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserListAdapter.java b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserListAdapter.java index 071f827c2..91539f132 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserListAdapter.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserListAdapter.java @@ -10,7 +10,6 @@ package net.gsantner.opoc.frontend.filebrowser; import android.content.Context; -import android.content.SharedPreferences; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; @@ -21,6 +20,7 @@ import android.text.TextUtils; import android.text.format.DateUtils; import android.text.style.StrikethroughSpan; +import android.util.Pair; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -38,7 +38,6 @@ import androidx.recyclerview.widget.RecyclerView; import net.gsantner.markor.R; -import net.gsantner.opoc.format.GsTextUtils; import net.gsantner.opoc.util.GsCollectionUtils; import net.gsantner.opoc.util.GsContextUtils; import net.gsantner.opoc.util.GsFileUtils; @@ -48,12 +47,14 @@ import java.io.FilenameFilter; import java.text.SimpleDateFormat; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.Stack; import java.util.concurrent.ExecutorService; import java.util.concurrent.SynchronousQueue; import java.util.concurrent.ThreadPoolExecutor; @@ -65,10 +66,12 @@ public class GsFileBrowserListAdapter extends RecyclerView.Adapter _adapterData; // List of current folder private final List _adapterDataFiltered; // Filtered list of current folder private final Set _currentSelection; - private File _currentFile; + private File _fileToShowAfterNextLoad; private File _currentFolder; private final Context _context; private StringFilter _filter; private RecyclerView _recyclerView; private LinearLayoutManager _layoutManager; - private final SharedPreferences _prefApp; - private final HashMap _virtualMapping = new HashMap<>(); + private final Map _virtualMapping; + private final Map _reverseVirtualMapping; private final Map _fileIdMap = new HashMap<>(); private final Map _folderScrollMap = new HashMap<>(); + private final Stack _backStack = new Stack<>(); + private long _prevModSum = 0; //######################## //## Methods @@ -102,7 +107,7 @@ public GsFileBrowserListAdapter(GsFileBrowserOptions.Options options, Context co _adapterDataFiltered = new ArrayList<>(); _currentSelection = new HashSet<>(); _context = context; - _prefApp = _context.getSharedPreferences("app", Context.MODE_PRIVATE); + GsContextUtils.instance.setAppLocale(_context, Locale.getDefault()); // Prevents view flicker - https://stackoverflow.com/a/32488059 setHasStableIds(true); @@ -130,9 +135,39 @@ public GsFileBrowserListAdapter(GsFileBrowserOptions.Options options, Context co _dopt.folderColor = cu.getResId(context, GsContextUtils.ResType.COLOR, "folder"); } + _virtualMapping = Collections.unmodifiableMap(getVirtualFolders()); + _reverseVirtualMapping = Collections.unmodifiableMap(GsCollectionUtils.reverse(_virtualMapping)); loadFolder(_dopt.startFolder != null ? _dopt.startFolder : _dopt.rootFolder, null); } + public Map getVirtualFolders() { + final GsContextUtils cu = GsContextUtils.instance; + + final Map map = new HashMap<>(); + + final File appDataFolder = _context.getFilesDir(); + if (appDataFolder.exists() || appDataFolder.mkdir()) { + map.put(VIRTUAL_STORAGE_APP_DATA_PRIVATE, appDataFolder); + } + + for (final File file : ContextCompat.getExternalFilesDirs(_context, null)) { + final File remap = new File(VIRTUAL_STORAGE_ROOT, "appdata-public (" + file.getName() + ")"); + map.put(remap, file); + } + + for (final Pair p : cu.getAppDataPublicDirs(_context, false, true, false)) { + final File remap = new File(VIRTUAL_STORAGE_ROOT, "sdcard (" + p.second + ")"); + map.put(remap, p.first); + } + + map.put(VIRTUAL_STORAGE_RECENTS, VIRTUAL_STORAGE_RECENTS); + map.put(VIRTUAL_STORAGE_POPULAR, VIRTUAL_STORAGE_POPULAR); + map.put(VIRTUAL_STORAGE_FAVOURITE, VIRTUAL_STORAGE_FAVOURITE); + map.put(VIRTUAL_STORAGE_EMULATED, VIRTUAL_STORAGE_EMULATED); + + return map; + } + @NonNull @Override public FilesystemViewerViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { @@ -151,48 +186,53 @@ public boolean isFileWriteable(File file, boolean isGoUp) { @Override @SuppressWarnings("ConstantConditions") public void onBindViewHolder(@NonNull FilesystemViewerViewHolder holder, int position) { - File file_pre = _adapterDataFiltered.get(position); - if (file_pre == null) { + final File displayFile = _adapterDataFiltered.get(position); + final File file; + if (displayFile == null) { holder.title.setText("????"); return; + } else if (_virtualMapping.containsKey(displayFile)) { + file = _virtualMapping.get(displayFile); + } else { + file = displayFile; } - GsContextUtils.instance.setAppLocale(_context, Locale.getDefault()); - final File file_pre_Parent = file_pre.getParentFile() == null ? new File("/") : file_pre.getParentFile(); - final String filename = file_pre.getName(); - if (_virtualMapping.containsKey(file_pre)) { - file_pre = _virtualMapping.get(file_pre); - } - final File file = file_pre; - final File fileParent = file.getParentFile() == null ? new File("/") : file.getParentFile(); - final File descriptionFile = file.equals(_currentFolder.getParentFile()) ? file : fileParent; - final boolean isGoUp = file.equals(_currentFolder.getParentFile()); - final boolean isSelected = _currentSelection.contains(file); - final boolean isFavourite = _dopt.favouriteFiles != null && _dopt.favouriteFiles.contains(file); - final boolean isPopular = _dopt.popularFiles != null && _dopt.popularFiles.contains(file); - final int descriptionRes = isSelected ? _dopt.contentDescriptionSelected : (file.isDirectory() ? _dopt.contentDescriptionFolder : _dopt.contentDescriptionFile); + + final String filename = displayFile.getName(); + final String currentFolderName = _currentFolder != null ? _currentFolder.getName() : ""; + final File currentFolderParent = _currentFolder != null ? _currentFolder.getParentFile() : null; + + final boolean isGoUp = VIRTUAL_STORAGE_ROOT.equals(displayFile) || file.equals(currentFolderParent); + final boolean isSelected = _currentSelection.contains(displayFile); + final boolean isFavourite = _dopt.favouriteFiles != null && _dopt.favouriteFiles.contains(displayFile); + final boolean isPopular = _dopt.popularFiles != null && _dopt.popularFiles.contains(displayFile); + final int descriptionRes = isSelected ? _dopt.contentDescriptionSelected : (displayFile.isDirectory() ? _dopt.contentDescriptionFolder : _dopt.contentDescriptionFile); + String titleText = filename; if (isCurrentFolderVirtual() && "index.html".equals(filename)) { - titleText += " [" + fileParent.getName() + "]"; + titleText += " [" + currentFolderName + "]"; } holder.title.setText(isGoUp ? ".." : titleText, TextView.BufferType.SPANNABLE); holder.title.setTextColor(ContextCompat.getColor(_context, _dopt.primaryTextColor)); - if (!isFileWriteable(file, isGoUp) && holder.title.length() > 0) { + + if (!isFileWriteable(displayFile, isGoUp) && !isVirtualFolder(displayFile) && holder.title.length() > 0) { try { ((Spannable) holder.title.getText()).setSpan(STRIKE_THROUGH_SPAN, 0, holder.title.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } catch (Exception ignored) { } } - final boolean isFile = file.isFile(); + final boolean isFile = displayFile.isFile(); - holder.description.setText(!_dopt.descModtimeInsteadOfParent || holder.title.getText().toString().equals("..") - ? descriptionFile.getAbsolutePath() : formatFileDescription(file, _prefApp.getString("pref_key__file_description_format", ""))); + holder.description.setText(!_dopt.descModtimeInsteadOfParent || isGoUp + ? file.getAbsolutePath() : formatFileDescription(file, _dopt.descriptionFormat)); holder.description.setTextColor(ContextCompat.getColor(_context, _dopt.secondaryTextColor)); + holder.image.setImageResource(isSelected ? _dopt.selectedItemImage : isFile ? _dopt.fileImage : _dopt.folderImage); holder.image.setColorFilter(ContextCompat.getColor(_context, - isSelected ? _dopt.accentColor : isFile ? _dopt.fileColor : _dopt.folderColor), + isSelected ? _dopt.accentColor : isFile? _dopt.fileColor : _dopt.folderColor), android.graphics.PorterDuff.Mode.SRC_ATOP); + if (!isSelected && isFavourite) { holder.image.setColorFilter(0xFFE3B51B); } @@ -204,10 +244,10 @@ public void onBindViewHolder(@NonNull FilesystemViewerViewHolder holder, int pos holder.itemRoot.setContentDescription((descriptionRes != 0 ? (_context.getString(descriptionRes) + " ") : "") + holder.title.getText().toString() + " " + holder.description.getText().toString()); holder.image.setOnLongClickListener(view -> { - Toast.makeText(_context, file.getAbsolutePath(), Toast.LENGTH_SHORT).show(); + Toast.makeText(_context, displayFile.getAbsolutePath(), Toast.LENGTH_SHORT).show(); return true; }); - holder.itemRoot.setTag(new TagContainer(file, position)); + holder.itemRoot.setTag(new TagContainer(displayFile, position)); holder.itemRoot.setOnClickListener(this); holder.itemRoot.setOnLongClickListener(this); @@ -254,7 +294,7 @@ public void restoreSavedInstanceState(final Bundle savedInstanceState) { final String path = savedInstanceState.getString(EXTRA_CURRENT_FOLDER); if (path != null) { final File f = new File(path); - final boolean isVirtualDirectory = _virtualMapping.containsKey(f) || isVirtualStorage(f); + final boolean isVirtualDirectory = _virtualMapping.containsKey(f) || isVirtualFolder(f); if (isVirtualDirectory && _dopt != null && _dopt.listener != null) { _dopt.listener.onFsViewerConfig(_dopt); @@ -361,10 +401,8 @@ public void onClick(View view) { case R.id.opoc_filesystem_item__root: { // A own item was clicked if (data.file != null) { - File file = data.file; - if (_virtualMapping.containsKey(file)) { - file = _virtualMapping.get(data.file); - } + final File file = GsCollectionUtils.getOrDefault(_virtualMapping, data.file, data.file); + if (areItemsSelected()) { // There are 1 or more items selected yet if (!toggleSelection(data) && file != null && file.isDirectory()) { @@ -372,10 +410,9 @@ public void onClick(View view) { } } else if (file != null) { // No pre-selection - if (file.isDirectory() || isVirtualStorage(file)) { - loadFolder(file, _currentFolder); + if (file.isDirectory() || isVirtualFolder(file)) { + loadFolder(file, isParent(file, _currentFolder) ? _currentFolder : null); } else if (file.isFile()) { - _currentFile = file; _dopt.listener.onFsViewerSelected(_dopt.requestId, file, null); } } @@ -452,11 +489,8 @@ public boolean toggleSelection(final TagContainer data) { } boolean clickHandled = false; - if (data.file != null && _currentFolder != null) { - if (data.file.isDirectory() && _currentFolder.getParentFile() != null && _currentFolder.getParentFile().equals(data.file)) { - // goUp - clickHandled = true; - } else if (_currentSelection.contains(data.file)) { + if (data.file != null && _currentFolder != null && !isParent(data.file, _currentFolder)) { + if (_currentSelection.contains(data.file)) { // Single selection _currentSelection.remove(data.file); clickHandled = true; @@ -481,15 +515,31 @@ public boolean toggleSelection(final TagContainer data) { return clickHandled; } + public boolean goBack() { + if (canGoBack()) { + final File show = GsCollectionUtils.getOrDefault(_reverseVirtualMapping, _currentFolder, _currentFolder); + loadFolder(GO_BACK_SIGNIFIER, show); + return true; + } + return false; + } + + public boolean canGoBack() { + return !_backStack.isEmpty(); + } + public boolean goUp() { - if (canGoUp()) { - final String absolutePath = _currentFolder.getAbsolutePath(); - if (_currentFolder != null && _currentFolder.getParentFile() != null && !_currentFolder.getParentFile().getAbsolutePath().equals(absolutePath)) { - unselectAll(); - loadFolder(_currentFolder.getParentFile(), _currentFolder); + if (_currentFolder != null && canGoUp()) { + if (_reverseVirtualMapping.containsKey(_currentFolder)) { + loadFolder(VIRTUAL_STORAGE_ROOT, _reverseVirtualMapping.get(_currentFolder)); return true; + } else { + final File parent = _currentFolder.getParentFile(); + if (parent != null) { + loadFolder(_currentFolder.getParentFile(), _currentFolder); + return true; + } } - return false; } return false; } @@ -499,8 +549,12 @@ public boolean canGoUp() { } public boolean canGoUp(final File folder) { - final File parent = folder != null ? folder.getParentFile() : null; - return parent != null && (!_dopt.mustStartWithRootFolder || parent.getAbsolutePath().startsWith(_dopt.rootFolder.getAbsolutePath())); + try { + final File parent = folder != null ? folder.getParentFile() : null; + return (parent != null && parent.canWrite()) || GsFileUtils.isChild(VIRTUAL_STORAGE_ROOT, folder); + } catch (SecurityException ignored) { + return false; + } } @Override @@ -551,7 +605,7 @@ public void showFile(final File file) { loadFolder(dir, file); } } else { - showAndFlash(file); + scrollToAndFlash(file); } } @@ -566,11 +620,11 @@ public void onLayoutChange(View v, int l, int t, int r, int b, int ol, int ot, i } /** - * Show a file in the current folder and blink it + * Scroll to a file in current folder and flash * * @param file File to blink */ - private void showAndFlash(final File file) { + public boolean scrollToAndFlash(final File file) { final int pos = getFilePosition(file); if (pos >= 0 && _layoutManager != null) { doAfterChange(() -> _recyclerView.postDelayed(() -> { @@ -580,7 +634,9 @@ private void showAndFlash(final File file) { } }, 400)); _layoutManager.scrollToPosition(pos); + return true; } + return false; } // Get the position of a file in the current view @@ -598,151 +654,136 @@ public int getFilePosition(final File file) { private static final ExecutorService executorService = new ThreadPoolExecutor(0, 3, 60, TimeUnit.SECONDS, new SynchronousQueue<>()); - private void loadFolder(final File folder, final @Nullable File toShow) { + private void loadFolder(final File folder, final File show) { + final boolean folderChanged = !folder.equals(_currentFolder); + if (folderChanged && _currentFolder != null && _layoutManager != null) { _folderScrollMap.put(_currentFolder, _layoutManager.onSaveInstanceState()); } - executorService.execute(() -> { - synchronized (_adapterData) { + final File toLoad; + if (GO_BACK_SIGNIFIER == folder) { + toLoad = _backStack.pop(); + } else { + if (folderChanged && _currentFolder != null) { + _backStack.push(_currentFolder); + } + toLoad = folder; + } - if (_dopt.refresh != null) { - _dopt.refresh.callback(); - } + if (_dopt.refresh != null) { + _dopt.refresh.callback(); + } - final HashMap virtualMapping = new HashMap<>(); - final List newData = new ArrayList<>(); - - if (folder.equals(VIRTUAL_STORAGE_ROOT)) { - // Scan for /storage/emulated/{0,1,2,..} - for (int i = 0; i < 10; i++) { - final File file = new File("/storage/emulated/" + i); - if (canWrite(file)) { - File remap = new File(folder, "emulated-" + i); - _virtualMapping.put(remap, file); - newData.add(remap); - } else { - break; - } - } + if (_fileToShowAfterNextLoad != null) { + _recyclerView.post(() -> { + scrollToAndFlash(_fileToShowAfterNextLoad); + _fileToShowAfterNextLoad = null; + }); + } - if (_dopt.recentFiles != null) { - virtualMapping.put(VIRTUAL_STORAGE_RECENTS, VIRTUAL_STORAGE_RECENTS); - newData.add(VIRTUAL_STORAGE_RECENTS); - } - if (_dopt.popularFiles != null) { - virtualMapping.put(VIRTUAL_STORAGE_POPULAR, VIRTUAL_STORAGE_POPULAR); - newData.add(VIRTUAL_STORAGE_POPULAR); - } - if (_dopt.favouriteFiles != null) { - virtualMapping.put(VIRTUAL_STORAGE_FAVOURITE, VIRTUAL_STORAGE_FAVOURITE); - newData.add(VIRTUAL_STORAGE_FAVOURITE); - } - final File appDataFolder = _context.getFilesDir(); - if (appDataFolder.exists() || appDataFolder.mkdir()) { - virtualMapping.put(VIRTUAL_STORAGE_APP_DATA_PRIVATE, appDataFolder); - newData.add(VIRTUAL_STORAGE_APP_DATA_PRIVATE); - } - } else if (folder.equals(VIRTUAL_STORAGE_RECENTS)) { - newData.addAll(_dopt.recentFiles); - } else if (folder.equals(VIRTUAL_STORAGE_POPULAR)) { - newData.addAll(_dopt.popularFiles); - } else if (folder.equals(VIRTUAL_STORAGE_FAVOURITE)) { - newData.addAll(_dopt.favouriteFiles); - } else if (folder.isDirectory()) { - GsCollectionUtils.addAll(newData, folder.listFiles(GsFileBrowserListAdapter.this)); - } + final File toShow = show == null ? _fileToShowAfterNextLoad : show; + _fileToShowAfterNextLoad = null; - // Some special folders get special children - if (folder.getAbsolutePath().equals("/storage/emulated")) { - newData.add(new File(folder, "0")); - } else if (folder.getAbsolutePath().equals("/")) { - newData.add(new File(folder, "storage")); - } else if (folder.equals(_context.getFilesDir().getParentFile())) { - // Private AppStorage: Allow to access to files directory only - // (don't allow access to internals like shared_preferences & databases) - newData.add(new File(folder, "files")); - } + executorService.execute(() -> _loadFolder(toLoad, toShow)); + } - for (final File externalFileDir : ContextCompat.getExternalFilesDirs(_context, null)) { - for (final File file : newData) { - final String absPath = file.getAbsolutePath(); - final String absExt = externalFileDir.getAbsolutePath(); - if (!canWrite(file) && !absPath.equals("/") && absExt.startsWith(absPath)) { - final int depth = GsTextUtils.countChars(absPath, '/')[0]; - if (depth < 3) { - final File parent = file.getParentFile(); - if (parent != null) { - final File remap = new File(parent.getAbsolutePath(), "appdata-public (" + file.getName() + ")"); - virtualMapping.put(remap, new File(absExt)); - newData.add(remap); - } - } - } - } - } + // This function is not called on the main thread, so post to the UI thread + private synchronized void _loadFolder(final @NonNull File folder, final @Nullable File toShow) { - // Don't sort recent items - use the default order - if (!folder.equals(VIRTUAL_STORAGE_RECENTS)) { - GsFileUtils.sortFiles(newData, _dopt.sortByType, _dopt.sortFolderFirst, _dopt.sortReverse); + final boolean folderChanged = !folder.equals(_currentFolder); + + final List newData = new ArrayList<>(); + + if (folder.equals(VIRTUAL_STORAGE_RECENTS)) { + newData.addAll(_dopt.recentFiles); + } else if (folder.equals(VIRTUAL_STORAGE_POPULAR)) { + newData.addAll(_dopt.popularFiles); + } else if (folder.equals(VIRTUAL_STORAGE_FAVOURITE)) { + newData.addAll(_dopt.favouriteFiles); + } else if (folder.isDirectory()) { + GsCollectionUtils.addAll(newData, folder.listFiles(GsFileBrowserListAdapter.this)); + } + + if (folder.equals(VIRTUAL_STORAGE_ROOT)) { + newData.addAll(_virtualMapping.keySet()); + } + + // Add all emulated folders under /storage/emulated + if (VIRTUAL_STORAGE_EMULATED.equals(folder)) { + newData.add(new File(folder, "0")); + for (int i = 1; i < 10; i++) { + final File f = new File(folder, String.valueOf(i)); + if (GsFileUtils.canCreate(f)) { + newData.add(f); } + } + } + + if (folder.getAbsolutePath().equals("/")) { + newData.add(new File(folder, VIRTUAL_STORAGE_ROOT.getName())); + } + + GsCollectionUtils.deduplicate(newData); - if (canGoUp(folder)) { - newData.add(0, folder.equals(new File("/storage/emulated/0")) ? new File("/storage/emulated") : folder.getParentFile()); + // Don't sort recent items - use the default order + if (!folder.equals(VIRTUAL_STORAGE_RECENTS)) { + GsFileUtils.sortFiles(newData, _dopt.sortByType, _dopt.sortFolderFirst, _dopt.sortReverse); + } + + // Testing if modtimes have changed (modtimes generally only increase) + final long modSum = GsCollectionUtils.accumulate(newData, (f, s) -> s + f.lastModified(), 0L); + final boolean modSumChanged = modSum != _prevModSum; + + if (canGoUp(folder)) { + if ( + isVirtualFolder(folder) || + _virtualMapping.containsValue(folder) || + !GsFileUtils.isChild(VIRTUAL_STORAGE_ROOT, folder) + ) { + newData.add(0, VIRTUAL_STORAGE_ROOT); + } else { + newData.add(0, folder.getParentFile()); + } + } + + if (folderChanged || modSumChanged || !newData.equals(_adapterData)) { + _recyclerView.post(() -> { + // Modify all these values in the UI thread + _adapterData.clear(); + _adapterData.addAll(newData); + _currentSelection.retainAll(_adapterData); + _filter.filter(_filter._lastFilter); + _currentFolder = folder; + _prevModSum = modSum; + + if (folderChanged) { + _fileIdMap.clear(); } - if (folderChanged || !newData.equals(_adapterData)) { - _recyclerView.post(() -> { - // Modify all these values in the UI thread - _adapterData.clear(); - _adapterData.addAll(newData); - _currentSelection.retainAll(_adapterData); - _filter.filter(_filter._lastFilter); - _currentFolder = folder; - - _virtualMapping.clear(); - _virtualMapping.putAll(virtualMapping); - - if (folderChanged) { - _fileIdMap.clear(); - } + // TODO - add logic to notify the changed bits + notifyDataSetChanged(); - // TODO - add logic to notify the changed bits - notifyDataSetChanged(); - - if (folderChanged) { - _recyclerView.post(() -> { - if (_layoutManager != null) { - _layoutManager.onRestoreInstanceState(_folderScrollMap.remove(_currentFolder)); - } - - if (GsFileUtils.isChild(_currentFolder, toShow)) { - _recyclerView.post(() -> showAndFlash(toShow)); - } - }); - } else if (toShow != null && _adapterData.contains(toShow)) { - _recyclerView.post(() -> showAndFlash(toShow)); + if (folderChanged) { + _recyclerView.post(() -> { + if (_layoutManager != null) { + _layoutManager.onRestoreInstanceState(_folderScrollMap.remove(_currentFolder)); } - if (_dopt.listener != null) { - _dopt.listener.onFsViewerDoUiUpdate(GsFileBrowserListAdapter.this); - } + _recyclerView.post(() -> scrollToAndFlash(toShow)); }); - } else if (toShow != null && _adapterData.contains(toShow)) { - showAndFlash(toShow); + } else if (toShow != null && _adapterDataFiltered.contains(toShow)) { + _recyclerView.post(() -> scrollToAndFlash(toShow)); } - if (_currentFile != null) { - _recyclerView.post(() -> { - showAndFlash(_currentFile); - final int position = getFilePosition(_currentFile); - if (position >= 0) notifyItemChanged(position); - _currentFile = null; - }); + if (_dopt.listener != null) { + _dopt.listener.onFsViewerDoUiUpdate(GsFileBrowserListAdapter.this); } - } - }); + }); + } else if (toShow != null && _adapterDataFiltered.contains(toShow)) { + scrollToAndFlash(toShow); + } } private boolean canWrite(File file) { @@ -776,13 +817,6 @@ public boolean isCurrentFolderHome() { return _currentFolder != null && _dopt.rootFolder != null && _dopt.rootFolder.getAbsolutePath().equals(_currentFolder.getAbsolutePath()); } - public static boolean isVirtualStorage(File file) { - return VIRTUAL_STORAGE_FAVOURITE.equals(file) || - VIRTUAL_STORAGE_APP_DATA_PRIVATE.equals(file) || - VIRTUAL_STORAGE_POPULAR.equals(file) || - VIRTUAL_STORAGE_RECENTS.equals(file); - } - //######################## //## //## StringFilter @@ -855,15 +889,18 @@ public static class FilesystemViewerViewHolder extends RecyclerView.ViewHolder { } public static boolean isVirtualFolder(final File file) { - return file != null && ( - file.equals(VIRTUAL_STORAGE_APP_DATA_PRIVATE) || - file.equals(VIRTUAL_STORAGE_FAVOURITE) || - file.equals(VIRTUAL_STORAGE_POPULAR) || - file.equals(VIRTUAL_STORAGE_RECENTS) || - file.equals(new File("/")) || - file.equals(new File("/storage")) || - file.equals(new File("/storage/self")) || - file.equals(new File("/storage/emulated")) - ); + return VIRTUAL_STORAGE_RECENTS.equals(file) || + VIRTUAL_STORAGE_FAVOURITE.equals(file) || + VIRTUAL_STORAGE_POPULAR.equals(file) || + VIRTUAL_STORAGE_APP_DATA_PRIVATE.equals(file) || + VIRTUAL_STORAGE_EMULATED.equals(file); + } + + private boolean isParent(File parent, File child) { + return (VIRTUAL_STORAGE_ROOT.equals(parent) && _virtualMapping.containsKey(child)) || GsFileUtils.isChild(parent, child); + } + + public void showFileAfterNextLoad(final File file) { + _fileToShowAfterNextLoad = file; } } diff --git a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserOptions.java b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserOptions.java index 7d9a641e9..46552f870 100644 --- a/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserOptions.java +++ b/app/src/main/java/net/gsantner/opoc/frontend/filebrowser/GsFileBrowserOptions.java @@ -49,14 +49,15 @@ public static class Options { public String requestId = "show_dialog"; public String sortByType = GsFileUtils.SORT_BY_NAME; + public String descriptionFormat = null; + // Dialog type public boolean doSelectFolder = true, doSelectFile = false, doSelectMultiple = false; - public boolean mustStartWithRootFolder = true, - sortFolderFirst = true, + public boolean sortFolderFirst = true, sortReverse = false, descModtimeInsteadOfParent = false, filterShowDotFiles = true; diff --git a/app/src/main/java/net/gsantner/opoc/util/GsCollectionUtils.java b/app/src/main/java/net/gsantner/opoc/util/GsCollectionUtils.java index 00e64c7cc..eb0fe9f51 100644 --- a/app/src/main/java/net/gsantner/opoc/util/GsCollectionUtils.java +++ b/app/src/main/java/net/gsantner/opoc/util/GsCollectionUtils.java @@ -18,9 +18,11 @@ import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; // Class for general utilities @@ -258,28 +260,23 @@ public static List range(final int... ops) { return values; } - public static class Holder { - private T value; - - public Holder(T value) { - this.value = value; - } - - public T get() { - return value; + public static Map reverse(final Map map) { + final Map reversed = new HashMap<>(); + for (final Map.Entry entry : map.entrySet()) { + reversed.put(entry.getValue(), entry.getKey()); } + return reversed; + } - public Holder set(T value) { - this.value = value; - return this; - } + public static V getOrDefault(final Map map, final K key, final V defaultValue) { + return map.containsKey(key) ? map.get(key) : defaultValue; + } - public T clear() { - try { - return value; - } finally { - value = null; - } + public static void deduplicate(final Collection data) { + if (!(data instanceof Set)) { + final LinkedHashSet deduped = new LinkedHashSet<>(data); + data.clear(); + data.addAll(deduped); } } } diff --git a/app/src/main/java/net/gsantner/opoc/util/GsContextUtils.java b/app/src/main/java/net/gsantner/opoc/util/GsContextUtils.java index e50cba848..a8a9406ed 100644 --- a/app/src/main/java/net/gsantner/opoc/util/GsContextUtils.java +++ b/app/src/main/java/net/gsantner/opoc/util/GsContextUtils.java @@ -58,6 +58,7 @@ import android.net.Uri; import android.os.Build; import android.os.Environment; +import android.os.Handler; import android.os.IBinder; import android.os.ParcelFileDescriptor; import android.os.SystemClock; @@ -2741,12 +2742,14 @@ public void dialogFullWidth(AlertDialog dialog, boolean fullWidth, boolean showK } } - public static void windowAspectRatio(final Window window, - final DisplayMetrics displayMetrics, - float portraitWidthRatio, - float portraitHeightRatio, - float landscapeWidthRatio, - float landscapeHeightRatio) { + public static void windowAspectRatio( + final Window window, + final DisplayMetrics displayMetrics, + float portraitWidthRatio, + float portraitHeightRatio, + float landscapeWidthRatio, + float landscapeHeightRatio + ) { if (window == null) { return; } diff --git a/app/src/main/java/net/gsantner/opoc/util/GsFileUtils.java b/app/src/main/java/net/gsantner/opoc/util/GsFileUtils.java index a9826fd0f..30ee5aa22 100644 --- a/app/src/main/java/net/gsantner/opoc/util/GsFileUtils.java +++ b/app/src/main/java/net/gsantner/opoc/util/GsFileUtils.java @@ -52,6 +52,7 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Date; import java.util.List; @@ -766,19 +767,27 @@ private static String makeSortKey(final String sortBy, final File file) { } public static void sortFiles( - final List filesToSort, + final Collection filesToSort, final String sortBy, final boolean folderFirst, final boolean reverse ) { if (filesToSort != null && !filesToSort.isEmpty()) { try { - GsCollectionUtils.keySort(filesToSort, (f) -> makeSortKey(sortBy, f)); + final boolean copy = !(filesToSort instanceof List); + final List sortable = copy ? new ArrayList<>(filesToSort) : (List) filesToSort; + + GsCollectionUtils.keySort(sortable, (f) -> makeSortKey(sortBy, f)); if (reverse) { - Collections.reverse(filesToSort); + Collections.reverse(sortable); } if (folderFirst) { - GsCollectionUtils.keySort(filesToSort, (f) -> !f.isDirectory()); + GsCollectionUtils.keySort(sortable, (f) -> !f.isDirectory()); + } + + if (copy) { + filesToSort.clear(); + filesToSort.addAll(sortable); } } catch (Exception e) { e.printStackTrace(); diff --git a/app/src/main/res/layout/opoc_filesystem_item.xml b/app/src/main/res/layout/opoc_filesystem_item.xml index 7d927d4aa..189ec04fc 100644 --- a/app/src/main/res/layout/opoc_filesystem_item.xml +++ b/app/src/main/res/layout/opoc_filesystem_item.xml @@ -50,7 +50,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_vertical" - android:ellipsize="middle" + android:ellipsize="start" android:importantForAccessibility="no" android:singleLine="true" android:textAppearance="@style/TextAppearance.AppCompat.Caption" diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index c3f975eeb..ee3999145 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -68,6 +68,8 @@ work. If not, see . Peida olekuriba Nimi URL / failitee + Sisesta link + Lingi vormistus Sisesta pilt Toetajad Määra asukohad diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index bea7dc1ab..307809d0e 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -1,6 +1,6 @@ - 18dp + 10dp 16dp 32dp 8dp diff --git a/app/src/main/res/values/string-not_translatable.xml b/app/src/main/res/values/string-not_translatable.xml index bf3c0ac8c..d816e37b5 100644 --- a/app/src/main/res/values/string-not_translatable.xml +++ b/app/src/main/res/values/string-not_translatable.xml @@ -422,6 +422,7 @@ work. If not, see . pref_key__filetype_template_map pref_key__template_title_format_map pref_key__title_format_list + pref_key__format_share_as_link Square Brackets CSV OrgMode diff --git a/build.gradle b/build.gradle index 391f04547..739a75558 100644 --- a/build.gradle +++ b/build.gradle @@ -20,9 +20,9 @@ buildscript { } repositories { + mavenCentral() maven { url 'https://maven.google.com' } maven { url "https://jitpack.io" } - mavenCentral() google() } @@ -43,7 +43,6 @@ allprojects { mavenCentral() maven { url 'https://maven.google.com' } maven { url "https://jitpack.io" } - jcenter() google() } diff --git a/doc/assets/todotxt-format-dark.png b/doc/assets/todotxt-format-dark.png new file mode 100644 index 000000000..e7becdb64 Binary files /dev/null and b/doc/assets/todotxt-format-dark.png differ